Repository: BookStackApp/BookStack

Issue:
Feature Request - Define number of shelves to display on one page
**Describe the feature you'd like**
The ability for an admin to set a global value for the number of shelves to display on one page before showing the next page option.

**Describe the benefits this feature would bring to BookStack users**
Allow users to chose how many shelves are displayed on a single page allowing a level of customisation (personal preference is a single page with all shelves rather than breaking it up in to pages). I understand there is probably a reason on the performance aspect which led to the current number. 




Discussion:
@SithumDissanayake: Hi there everyone, I was just wondering if this has become a feature in BookStack now?@flobout: I'm also interested in this feature.
In our case, there is a second page just for one shelf, which is disturbing.
Can you tell me where to increase the number of shelves displayed on one page ?
@flobout: I would like to thank the person who gave us the information @SithumDissanayake: Would you be able to elaborate on how you accomplished this?@flobout: This feature is still not implemented. I'm still waiting, sorry I expressed myself incorrectly.@ssddanbrown: After the build of #5606 by @Xenoamor, and the follow-up changes after review, this functionality has now been added and will be part of the next feature release.
There will be page-limit controls for shelves, books and search results.

Solution patch:
diff --git a/.env.example b/.env.example
index 4dee3b334..13114b8b0 100644
--- a/.env.example
+++ b/.env.example
@@ -26,6 +26,13 @@ DB_DATABASE=database_database
 DB_USERNAME=database_username
 DB_PASSWORD=database_user_password
 
+# Storage system to use
+# By default files are stored on the local filesystem, with images being placed in
+# public web space so they can be efficiently served directly by the web-server.
+# For other options with different security levels & considerations, refer to:
+# https://www.bookstackapp.com/docs/admin/upload-config/
+STORAGE_TYPE=local
+
 # Mail system to use
 # Can be 'smtp' or 'sendmail'
 MAIL_DRIVER=smtp
diff --git a/.env.example.complete b/.env.example.complete
index 25687aaac..18e7bd00d 100644
--- a/.env.example.complete
+++ b/.env.example.complete
@@ -36,10 +36,14 @@ APP_LANG=en
 # APP_LANG will be used if such a header is not provided.
 APP_AUTO_LANG_PUBLIC=true
 
-# Application timezone
-# Used where dates are displayed such as on exported content.
+# Application timezones
+# The first option is used to determine what timezone is used for date storage.
+# Leaving that as "UTC" is advised.
+# The second option is used to set the timezone which will be used for date
+# formatting and display. This defaults to the "APP_TIMEZONE" value.
 # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
 APP_TIMEZONE=UTC
+APP_DISPLAY_TIMEZONE=UTC
 
 # Application theme
 # Used to specific a themes/<APP_THEME> folder where BookStack UI
diff --git a/.github/ISSUE_TEMPLATE/support_request.yml b/.github/ISSUE_TEMPLATE/support_request.yml
index 4db8d1ced..ae808a0b6 100644
--- a/.github/ISSUE_TEMPLATE/support_request.yml
+++ b/.github/ISSUE_TEMPLATE/support_request.yml
@@ -42,6 +42,7 @@ body:
       label: Log Content
       description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
       placeholder: Be sure to remove any confidential details in your logs
+      render: text
     validations:
       required: false
   - type: textarea
diff --git a/.github/ISSUE_TEMPLATE/z_blank_request.yml b/.github/ISSUE_TEMPLATE/z_blank_request.yml
new file mode 100644
index 000000000..5cb5ed2a4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/z_blank_request.yml
@@ -0,0 +1,9 @@
+name: Blank Request (Maintainers Only)
+description: For maintainers only - Start a blank request
+body:
+  - type: markdown
+    attributes:
+      value: "**This blank request option is only for existing official maintainers of the project!** Please instead use a different request option. If you use this your issue will be closed off."
+  - type: textarea
+    attributes:
+      label: Description
\ No newline at end of file
diff --git a/.github/translators.txt b/.github/translators.txt
index aab08d764..67eea4874 100644
--- a/.github/translators.txt
+++ b/.github/translators.txt
@@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
 Rem (Rem9000) :: Dutch
 Michał Stelmach (stelmach-web) :: Polish
 arniom :: French
-REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish; 
+REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish; 
 林祖年 (contagion) :: Chinese Traditional
 Siamak Guodarzi (siamakgoudarzi88) :: Persian
 Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -222,7 +222,7 @@ SmokingCrop :: Dutch
 Maciej Lebiest (Szwendacz) :: Polish
 DiscordDigital :: German; German Informal
 Gábor Marton (dodver) :: Hungarian
-Jasell :: Swedish
+Jakob Åsell (Jasell) :: Swedish
 Ghost_chu (ghostchu) :: Chinese Simplified
 Ravid Shachar (ravidshachar) :: Hebrew
 Helga Guchshenskaya (guchshenskaya) :: Russian
@@ -438,7 +438,7 @@ javadataherian :: Persian
 Ludo-code :: French
 hollsten :: Swedish
 Ngoc Lan Phung (lanpncz) :: Vietnamese
-Worive :: Catalan
+Worive :: Catalan; French
 Илья Скаба (skabailya) :: Russian
 Irjan Olsen (Irch) :: Norwegian Bokmal
 Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
@@ -487,3 +487,28 @@ jellium :: French
 Qxlkdr :: Swedish
 Hari (muhhari) :: Indonesian
 仙君御 (xjy) :: Chinese Simplified
+TapioM :: Finnish
+lingb58 :: Chinese Traditional
+Angel Pandey (angel-pandey) :: Nepali
+Supriya Shrestha (supriyashrestha) :: Nepali
+gprabhat :: Nepali
+CellCat :: Chinese Simplified
+Al Desrahim (aldesrahim) :: Indonesian
+ahmad abbaspour (deshneh.dar.diss) :: Persian
+Erjon K. (ekr) :: Albanian
+LiZerui (iamzrli) :: Chinese Traditional
+Ticker (ticker.com) :: Hebrew
+CrazyComputer :: Chinese Simplified
+Firr (FirrV) :: Russian
+João Faro (FaroJoaoFaro) :: Portuguese
+Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian
+Chris (furesoft) :: German
+Silvia Isern (eiendragon) :: Catalan
+Dennis Kron Pedersen (ahjdp) :: Danish
+iamwhoiamwhoami :: Swedish
+Grogui :: French
+MrCharlesIII :: Arabic
+David Olsen (dawin) :: Danish
+ltnzr :: French
+Frank Holler (holler.frank) :: German; German Informal
+Korab Arifi (korabidev) :: Albanian
diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml
index 23e58a772..80075c3f7 100644
--- a/.github/workflows/test-migrations.yml
+++ b/.github/workflows/test-migrations.yml
@@ -16,7 +16,7 @@ jobs:
     runs-on: ubuntu-24.04
     strategy:
       matrix:
-        php: ['8.2', '8.3', '8.4']
+        php: ['8.2', '8.3', '8.4', '8.5']
     steps:
       - uses: actions/checkout@v4
 
diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml
index 64132f673..5f4c16caf 100644
--- a/.github/workflows/test-php.yml
+++ b/.github/workflows/test-php.yml
@@ -16,7 +16,7 @@ jobs:
     runs-on: ubuntu-24.04
     strategy:
       matrix:
-        php: ['8.2', '8.3', '8.4']
+        php: ['8.2', '8.3', '8.4', '8.5']
     steps:
     - uses: actions/checkout@v4
 
diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php
index 2b5ddfbf3..2165fd459 100644
--- a/app/Access/ExternalBaseUserProvider.php
+++ b/app/Access/ExternalBaseUserProvider.php
@@ -2,33 +2,18 @@
 
 namespace BookStack\Access;
 
+use BookStack\Users\Models\User;
 use Illuminate\Contracts\Auth\Authenticatable;
 use Illuminate\Contracts\Auth\UserProvider;
-use Illuminate\Database\Eloquent\Model;
 
 class ExternalBaseUserProvider implements UserProvider
 {
-    public function __construct(
-        protected string $model
-    ) {
-    }
-
-    /**
-     * Create a new instance of the model.
-     */
-    public function createModel(): Model
-    {
-        $class = '\\' . ltrim($this->model, '\\');
-
-        return new $class();
-    }
-
     /**
      * Retrieve a user by their unique identifier.
      */
     public function retrieveById(mixed $identifier): ?Authenticatable
     {
-        return $this->createModel()->newQuery()->find($identifier);
+        return User::query()->find($identifier);
     }
 
     /**
@@ -59,10 +44,7 @@ class ExternalBaseUserProvider implements UserProvider
      */
     public function retrieveByCredentials(array $credentials): ?Authenticatable
     {
-        // Search current user base by looking up a uid
-        $model = $this->createModel();
-
-        return $model->newQuery()
+        return User::query()
             ->where('external_auth_id', $credentials['external_auth_id'])
             ->first();
     }
diff --git a/app/Access/Guards/AsyncExternalBaseSessionGuard.php b/app/Access/Guards/AsyncExternalBaseSessionGuard.php
index ab982a6b0..b66fbe95e 100644
--- a/app/Access/Guards/AsyncExternalBaseSessionGuard.php
+++ b/app/Access/Guards/AsyncExternalBaseSessionGuard.php
@@ -3,23 +3,18 @@
 namespace BookStack\Access\Guards;
 
 /**
- * Saml2 Session Guard.
+ * External Auth Session Guard.
  *
- * The saml2 login process is async in nature meaning it does not fit very well
- * into the default laravel 'Guard' auth flow. Instead most of the logic is done
- * via the Saml2 controller & Saml2Service. This class provides a safer, thin
- * version of SessionGuard.
+ * The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well
+ * into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant
+ * controller and services. This class provides a safer, thin version of SessionGuard.
  */
 class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
 {
     /**
      * Validate a user's credentials.
-     *
-     * @param array $credentials
-     *
-     * @return bool
      */
-    public function validate(array $credentials = [])
+    public function validate(array $credentials = []): bool
     {
         return false;
     }
@@ -27,12 +22,9 @@ class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
     /**
      * Attempt to authenticate a user using the given credentials.
      *
-     * @param array $credentials
      * @param bool  $remember
-     *
-     * @return bool
      */
-    public function attempt(array $credentials = [], $remember = false)
+    public function attempt(array $credentials = [], $remember = false): bool
     {
         return false;
     }
diff --git a/app/Access/Guards/ExternalBaseSessionGuard.php b/app/Access/Guards/ExternalBaseSessionGuard.php
index 2d15422a5..91239599b 100644
--- a/app/Access/Guards/ExternalBaseSessionGuard.php
+++ b/app/Access/Guards/ExternalBaseSessionGuard.php
@@ -4,7 +4,7 @@ namespace BookStack\Access\Guards;
 
 use BookStack\Access\RegistrationService;
 use Illuminate\Auth\GuardHelpers;
-use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
+use Illuminate\Contracts\Auth\Authenticatable;
 use Illuminate\Contracts\Auth\StatefulGuard;
 use Illuminate\Contracts\Auth\UserProvider;
 use Illuminate\Contracts\Session\Session;
@@ -24,43 +24,31 @@ class ExternalBaseSessionGuard implements StatefulGuard
      * The name of the Guard. Typically "session".
      *
      * Corresponds to guard name in authentication configuration.
-     *
-     * @var string
      */
-    protected $name;
+    protected readonly string $name;
 
     /**
      * The user we last attempted to retrieve.
-     *
-     * @var \Illuminate\Contracts\Auth\Authenticatable
      */
-    protected $lastAttempted;
+    protected Authenticatable|null $lastAttempted;
 
     /**
      * The session used by the guard.
-     *
-     * @var \Illuminate\Contracts\Session\Session
      */
-    protected $session;
+    protected Session $session;
 
     /**
      * Indicates if the logout method has been called.
-     *
-     * @var bool
      */
-    protected $loggedOut = false;
+    protected bool $loggedOut = false;
 
     /**
      * Service to handle common registration actions.
-     *
-     * @var RegistrationService
      */
-    protected $registrationService;
+    protected RegistrationService $registrationService;
 
     /**
      * Create a new authentication guard.
-     *
-     * @return void
      */
     public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
     {
@@ -72,13 +60,11 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
     /**
      * Get the currently authenticated user.
-     *
-     * @return \Illuminate\Contracts\Auth\Authenticatable|null
      */
-    public function user()
+    public function user(): Authenticatable|null
     {
         if ($this->loggedOut) {
-            return;
+            return null;
         }
 
         // If we've already retrieved the user for the current request we can just
@@ -101,13 +87,11 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
     /**
      * Get the ID for the currently authenticated user.
-     *
-     * @return int|null
      */
-    public function id()
+    public function id(): int|null
     {
         if ($this->loggedOut) {
-            return;
+            return null;
         }
 
         return $this->user()
@@ -117,12 +101,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
     /**
      * Log a user into the application without sessions or cookies.
-     *
-     * @param array $credentials
-     *
-     * @return bool
      */
-    public function once(array $credentials = [])
+    public function once(array $credentials = []): bool
     {
         if ($this->validate($credentials)) {
             $this->setUser($this->lastAttempted);
@@ -135,12 +115,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
     /**
      * Log the given user ID into the application without sessions or cookies.
-     *
-     * @param mixed $id
-     *
-     * @return \Illuminate\Contracts\Auth\Authenticatable|false
      */
-    public function onceUsingId($id)
+    public function onceUsingId($id): Authenticatable|false
     {
         if (!is_null($user = $this->provider->retrieveById($id))) {
             $this->setUser($user);
@@ -153,38 +129,26 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
     /**
      * Validate a user's credentials.
-     *
-     * @param array $credentials
-     *
-     * @return bool
      */
-    public function validate(array $credentials = [])
+    public function validate(array $credentials = []): bool
     {
         return false;
     }
 
     /**
      * Attempt to authenticate a user using the given credentials.
-     *
-     * @param array $credentials
-     * @param bool  $remember
-     *
-     * @return bool
+     * @param bool $remember
      */
-    public function attempt(array $credentials = [], $remember = false)
+    public function attempt(array $credentials = [], $remember = false): bool
     {
         return false;
     }
 
     /**
      * Log the given user ID into the application.
-     *
-     * @param mixed $id
      * @param bool  $remember
-     *
-     * @return \Illuminate\Contracts\Auth\Authenticatable|false
      */
-    public function loginUsingId($id, $remember = false)
+    public function loginUsingId(mixed $id, $remember = false): Authenticatable|false
     {
         // Always return false as to disable this method,
         // Logins should route through LoginService.
@@ -194,12 +158,9 @@ class ExternalBaseSessionGuard implements StatefulGuard
     /**
      * Log a user into the application.
      *
-     * @param \Illuminate\Contracts\Auth\Authenticatable $user
-     * @param bool                                       $remember
-     *
-     * @return void
+     * @param bool $remember
      */
-    public function login(AuthenticatableContract $user, $remember = false)
+    public function login(Authenticatable $user, $remember = false): void
     {
         $this->updateSession($user->getAuthIdentifier());
 
@@ -208,12 +169,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
     /**
      * Update the session with the given ID.
-     *
-     * @param string $id
-     *
-     * @return void
      */
-    protected function updateSession($id)
+    protected function updateSession(string|int $id): void
     {
         $this->session->put($this->getName(), $id);
 
@@ -222,10 +179,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
     /**
      * Log the user out of the application.
-     *
-     * @return void
      */
-    public function logout()
+    public function logout(): void
     {
         $this->clearUserDataFromStorage();
 
@@ -239,62 +194,48 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
     /**
      * Remove the user data from the session and cookies.
-     *
-     * @return void
      */
-    protected function clearUserDataFromStorage()
+    protected function clearUserDataFromStorage(): void
     {
         $this->session->remove($this->getName());
     }
 
     /**
      * Get the last user we attempted to authenticate.
-     *
-     * @return \Illuminate\Contracts\Auth\Authenticatable
      */
-    public function getLastAttempted()
+    public function getLastAttempted(): Authenticatable
     {
         return $this->lastAttempted;
     }
 
     /**
      * Get a unique identifier for the auth session value.
-     *
-     * @return string
      */
-    public function getName()
+    public function getName(): string
     {
         return 'login_' . $this->name . '_' . sha1(static::class);
     }
 
     /**
      * Determine if the user was authenticated via "remember me" cookie.
-     *
-     * @return bool
      */
-    public function viaRemember()
+    public function viaRemember(): bool
     {
         return false;
     }
 
     /**
      * Return the currently cached user.
-     *
-     * @return \Illuminate\Contracts\Auth\Authenticatable|null
      */
-    public function getUser()
+    public function getUser(): Authenticatable|null
     {
         return $this->user;
     }
 
     /**
      * Set the current user.
-     *
-     * @param \Illuminate\Contracts\Auth\Authenticatable $user
-     *
-     * @return $this
      */
-    public function setUser(AuthenticatableContract $user)
+    public function setUser(Authenticatable $user): self
     {
         $this->user = $user;
 
diff --git a/app/Access/Guards/LdapSessionGuard.php b/app/Access/Guards/LdapSessionGuard.php
index a417d3e61..9455d530d 100644
--- a/app/Access/Guards/LdapSessionGuard.php
+++ b/app/Access/Guards/LdapSessionGuard.php
@@ -35,13 +35,9 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
     /**
      * Validate a user's credentials.
      *
-     * @param array $credentials
-     *
      * @throws LdapException
-     *
-     * @return bool
      */
-    public function validate(array $credentials = [])
+    public function validate(array $credentials = []): bool
     {
         $userDetails = $this->ldapService->getUserDetails($credentials['username']);
 
@@ -57,16 +53,13 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
     /**
      * Attempt to authenticate a user using the given credentials.
      *
-     * @param array $credentials
      * @param bool  $remember
      *
-     * @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
+     * @throws LdapException
      * @throws LoginAttemptException
      * @throws JsonDebugException
-     *
-     * @return bool
      */
-    public function attempt(array $credentials = [], $remember = false)
+    public function attempt(array $credentials = [], $remember = false): bool
     {
         $username = $credentials['username'];
         $userDetails = $this->ldapService->getUserDetails($username);
diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php
index 6607969af..c81e95572 100644
--- a/app/Access/LoginService.php
+++ b/app/Access/LoginService.php
@@ -9,6 +9,7 @@ use BookStack\Exceptions\LoginAttemptInvalidUserException;
 use BookStack\Exceptions\StoppedAuthenticationException;
 use BookStack\Facades\Activity;
 use BookStack\Facades\Theme;
+use BookStack\Permissions\Permission;
 use BookStack\Theming\ThemeEvents;
 use BookStack\Users\Models\User;
 use Exception;
@@ -50,7 +51,7 @@ class LoginService
         Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
 
         // Authenticate on all session guards if a likely admin
-        if ($user->can('users-manage') && $user->can('user-roles-manage')) {
+        if ($user->can(Permission::UsersManage) && $user->can(Permission::UserRolesManage)) {
             $guards = ['standard', 'ldap', 'saml2', 'oidc'];
             foreach ($guards as $guard) {
                 auth($guard)->login($user);
@@ -95,7 +96,7 @@ class LoginService
     {
         $value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
         if (!$value) {
-            return ['user_id' => null, 'method' => null];
+            return ['user_id' => null, 'method' => null, 'remember' => false];
         }
 
         [$id, $method, $remember, $time] = explode(':', $value);
@@ -103,18 +104,18 @@ class LoginService
         if ($time < $hourAgo) {
             $this->clearLastLoginAttempted();
 
-            return ['user_id' => null, 'method' => null];
+            return ['user_id' => null, 'method' => null, 'remember' => false];
         }
 
         return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
     }
 
     /**
-     * Set the last login attempted user.
+     * Set the last login-attempted user.
      * Must be only used when credentials are correct and a login could be
-     * achieved but a secondary factor has stopped the login.
+     * achieved, but a secondary factor has stopped the login.
      */
-    protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
+    protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember): void
     {
         session()->put(
             self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
diff --git a/app/Access/Mfa/MfaSession.php b/app/Access/Mfa/MfaSession.php
index 09b9e53b8..b12853412 100644
--- a/app/Access/Mfa/MfaSession.php
+++ b/app/Access/Mfa/MfaSession.php
@@ -11,7 +11,6 @@ class MfaSession
      */
     public function isRequiredForUser(User $user): bool
     {
-        // TODO - Test both these cases
         return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
     }
 
diff --git a/app/Access/Mfa/MfaValue.php b/app/Access/Mfa/MfaValue.php
index 64d20eb18..dd3e04618 100644
--- a/app/Access/Mfa/MfaValue.php
+++ b/app/Access/Mfa/MfaValue.php
@@ -4,6 +4,7 @@ namespace BookStack\Access\Mfa;
 
 use BookStack\Users\Models\User;
 use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 
 /**
@@ -16,6 +17,8 @@ use Illuminate\Database\Eloquent\Model;
  */
 class MfaValue extends Model
 {
+    use HasFactory;
+
     protected static $unguarded = true;
 
     const METHOD_TOTP = 'totp';
diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php
index 7c1760649..d6f6ef156 100644
--- a/app/Access/Oidc/OidcService.php
+++ b/app/Access/Oidc/OidcService.php
@@ -11,6 +11,7 @@ use BookStack\Exceptions\UserRegistrationException;
 use BookStack\Facades\Theme;
 use BookStack\Http\HttpRequestService;
 use BookStack\Theming\ThemeEvents;
+use BookStack\Uploads\UserAvatars;
 use BookStack\Users\Models\User;
 use Illuminate\Support\Facades\Cache;
 use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
@@ -26,7 +27,8 @@ class OidcService
         protected RegistrationService $registrationService,
         protected LoginService $loginService,
         protected HttpRequestService $http,
-        protected GroupSyncService $groupService
+        protected GroupSyncService $groupService,
+        protected UserAvatars $userAvatars
     ) {
     }
 
@@ -220,6 +222,10 @@ class OidcService
             throw new OidcException($exception->getMessage());
         }
 
+        if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) {
+            $this->userAvatars->assignToUserFromUrl($user, $userDetails->picture);
+        }
+
         if ($this->shouldSyncGroups()) {
             $detachExisting = $this->config()['remove_from_groups'];
             $this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
diff --git a/app/Access/Oidc/OidcUserDetails.php b/app/Access/Oidc/OidcUserDetails.php
index fae20de0b..7a422a58d 100644
--- a/app/Access/Oidc/OidcUserDetails.php
+++ b/app/Access/Oidc/OidcUserDetails.php
@@ -11,6 +11,7 @@ class OidcUserDetails
         public ?string $email = null,
         public ?string $name = null,
         public ?array $groups = null,
+        public ?string $picture = null,
     ) {
     }
 
@@ -40,15 +41,16 @@ class OidcUserDetails
         $this->email = $claims->getClaim('email') ?? $this->email;
         $this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
         $this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
+        $this->picture = static::getPicture($claims) ?: $this->picture;
     }
 
-    protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string
+    protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string
     {
         $displayNameClaimParts = explode('|', $displayNameClaims);
 
         $displayName = [];
         foreach ($displayNameClaimParts as $claim) {
-            $component = $token->getClaim(trim($claim)) ?? '';
+            $component = $claims->getClaim(trim($claim)) ?? '';
             if ($component !== '') {
                 $displayName[] = $component;
             }
@@ -57,13 +59,13 @@ class OidcUserDetails
         return implode(' ', $displayName);
     }
 
-    protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): ?array
+    protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array
     {
         if (empty($groupsClaim)) {
             return null;
         }
 
-        $groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
+        $groupsList = Arr::get($claims->getAllClaims(), $groupsClaim);
         if (!is_array($groupsList)) {
             return null;
         }
@@ -72,4 +74,14 @@ class OidcUserDetails
             return is_string($val);
         }));
     }
+
+    protected static function getPicture(ProvidesClaims $claims): ?string
+    {
+        $picture = $claims->getClaim('picture');
+        if (is_string($picture) && str_starts_with($picture, 'http')) {
+            return $picture;
+        }
+
+        return null;
+    }
 }
diff --git a/app/Access/Saml2Service.php b/app/Access/Saml2Service.php
index bb7e9b572..106a7a229 100644
--- a/app/Access/Saml2Service.php
+++ b/app/Access/Saml2Service.php
@@ -51,7 +51,7 @@ class Saml2Service
      * Returns the SAML2 request ID, and the URL to redirect the user to.
      *
      * @throws Error
-     * @returns array{url: string, id: ?string}
+     * @return array{url: string, id: ?string}
      */
     public function logout(User $user): array
     {
diff --git a/app/Access/SocialAccount.php b/app/Access/SocialAccount.php
index b76dbb9ec..f52f74cc4 100644
--- a/app/Access/SocialAccount.php
+++ b/app/Access/SocialAccount.php
@@ -5,18 +5,23 @@ namespace BookStack\Access;
 use BookStack\Activity\Models\Loggable;
 use BookStack\App\Model;
 use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
- * Class SocialAccount.
- *
  * @property string $driver
  * @property User   $user
  */
 class SocialAccount extends Model implements Loggable
 {
-    protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
+    use HasFactory;
 
-    public function user()
+    protected $fillable = ['user_id', 'driver', 'driver_id'];
+
+    /**
+     * @return BelongsTo<User, $this>
+     */
+    public function user(): BelongsTo
     {
         return $this->belongsTo(User::class);
     }
diff --git a/app/Access/SocialDriverManager.php b/app/Access/SocialDriverManager.php
index dafc0e82d..efafab560 100644
--- a/app/Access/SocialDriverManager.php
+++ b/app/Access/SocialDriverManager.php
@@ -55,7 +55,7 @@ class SocialDriverManager
 
     /**
      * Gets the names of the active social drivers, keyed by driver id.
-     * @returns array<string, string>
+     * @return array<string, string>
      */
     public function getActive(): array
     {
diff --git a/app/Activity/ActivityQueries.php b/app/Activity/ActivityQueries.php
index 86326fb80..d5b047937 100644
--- a/app/Activity/ActivityQueries.php
+++ b/app/Activity/ActivityQueries.php
@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\MixedEntityListLoader;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Users\Models\User;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
 use Illuminate\Database\Eloquent\Relations\Relation;
 
 class ActivityQueries
@@ -67,6 +68,7 @@ class ActivityQueries
 
         $activity = $query->orderBy('created_at', 'desc')
             ->with(['loggable' => function (Relation $query) {
+                /** @var MorphTo<Entity, Activity> $query */
                 $query->withTrashed();
             }, 'user.avatar'])
             ->skip($count * ($page - 1))
diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php
index 3336e17e9..1802e3905 100644
--- a/app/Activity/CommentRepo.php
+++ b/app/Activity/CommentRepo.php
@@ -4,8 +4,11 @@ namespace BookStack\Activity;
 
 use BookStack\Activity\Models\Comment;
 use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\NotifyException;
 use BookStack\Facades\Activity as ActivityService;
 use BookStack\Util\HtmlDescriptionFilter;
+use Illuminate\Database\Eloquent\Builder;
 
 class CommentRepo
 {
@@ -17,11 +20,46 @@ class CommentRepo
         return Comment::query()->findOrFail($id);
     }
 
+    /**
+     * Get a comment by ID, ensuring it is visible to the user based upon access to the page
+     * which the comment is attached to.
+     */
+    public function getVisibleById(int $id): Comment
+    {
+        return $this->getQueryForVisible()->findOrFail($id);
+    }
+
+    /**
+     * Start a query for comments visible to the user.
+     * @return Builder<Comment>
+     */
+    public function getQueryForVisible(): Builder
+    {
+        return Comment::query()->scopes('visible');
+    }
+
     /**
      * Create a new comment on an entity.
      */
-    public function create(Entity $entity, string $html, ?int $parent_id): Comment
+    public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
     {
+        // Prevent comments being added to draft pages
+        if ($entity instanceof Page && $entity->draft) {
+            throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
+        }
+
+        // Validate parent ID
+        if ($parentId !== null) {
+            $parentCommentExists = Comment::query()
+                ->where('commentable_id', '=', $entity->id)
+                ->where('commentable_type', '=', $entity->getMorphClass())
+                ->where('local_id', '=', $parentId)
+                ->exists();
+            if (!$parentCommentExists) {
+                $parentId = null;
+            }
+        }
+
         $userId = user()->id;
         $comment = new Comment();
 
@@ -29,12 +67,14 @@ class CommentRepo
         $comment->created_by = $userId;
         $comment->updated_by = $userId;
         $comment->local_id = $this->getNextLocalId($entity);
-        $comment->parent_id = $parent_id;
+        $comment->parent_id = $parentId;
+        $comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
 
         $entity->comments()->save($comment);
         ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
         ActivityService::add(ActivityType::COMMENTED_ON, $entity);
 
+        $comment->refresh()->unsetRelations();
         return $comment;
     }
 
@@ -52,6 +92,45 @@ class CommentRepo
         return $comment;
     }
 
+
+    /**
+     * Archive an existing comment.
+     */
+    public function archive(Comment $comment, bool $log = true): Comment
+    {
+        if ($comment->parent_id) {
+            throw new NotifyException('Only top-level comments can be archived.', '/', 400);
+        }
+
+        $comment->archived = true;
+        $comment->save();
+
+        if ($log) {
+            ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+        }
+
+        return $comment;
+    }
+
+    /**
+     * Un-archive an existing comment.
+     */
+    public function unarchive(Comment $comment, bool $log = true): Comment
+    {
+        if ($comment->parent_id) {
+            throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
+        }
+
+        $comment->archived = false;
+        $comment->save();
+
+        if ($log) {
+            ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+        }
+
+        return $comment;
+    }
+
     /**
      * Delete a comment from the system.
      */
diff --git a/app/Activity/Controllers/AuditLogApiController.php b/app/Activity/Controllers/AuditLogApiController.php
index 650d17446..0cb4d9cb6 100644
--- a/app/Activity/Controllers/AuditLogApiController.php
+++ b/app/Activity/Controllers/AuditLogApiController.php
@@ -4,6 +4,7 @@ namespace BookStack\Activity\Controllers;
 
 use BookStack\Activity\Models\Activity;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 
 class AuditLogApiController extends ApiController
 {
@@ -16,8 +17,8 @@ class AuditLogApiController extends ApiController
      */
     public function list()
     {
-        $this->checkPermission('settings-manage');
-        $this->checkPermission('users-manage');
+        $this->checkPermission(Permission::SettingsManage);
+        $this->checkPermission(Permission::UsersManage);
 
         $query = Activity::query()->with(['user']);
 
diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php
index 66ca30197..c4f9b91ed 100644
--- a/app/Activity/Controllers/AuditLogController.php
+++ b/app/Activity/Controllers/AuditLogController.php
@@ -5,6 +5,7 @@ namespace BookStack\Activity\Controllers;
 use BookStack\Activity\ActivityType;
 use BookStack\Activity\Models\Activity;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Sorting\SortUrl;
 use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
@@ -13,8 +14,8 @@ class AuditLogController extends Controller
 {
     public function index(Request $request)
     {
-        $this->checkPermission('settings-manage');
-        $this->checkPermission('users-manage');
+        $this->checkPermission(Permission::SettingsManage);
+        $this->checkPermission(Permission::UsersManage);
 
         $sort = $request->get('sort', 'activity_date');
         $order = $request->get('order', 'desc');
diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php
new file mode 100644
index 000000000..6c60de9da
--- /dev/null
+++ b/app/Activity/Controllers/CommentApiController.php
@@ -0,0 +1,148 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BookStack\Activity\Controllers;
+
+use BookStack\Activity\CommentRepo;
+use BookStack\Activity\Models\Comment;
+use BookStack\Entities\Queries\PageQueries;
+use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+
+/**
+ * The comment data model has a 'local_id' property, which is a unique integer ID
+ * scoped to the page which the comment is on. The 'parent_id' is used for replies
+ * and refers to the 'local_id' of the parent comment on the same page, not the main
+ * globally unique 'id'.
+ *
+ * If you want to get all comments for a page in a tree-like structure, as reflected in
+ * the UI, then that is provided on pages-read API responses.
+ */
+class CommentApiController extends ApiController
+{
+    protected array $rules = [
+        'create' => [
+            'page_id' => ['required', 'integer'],
+            'reply_to' => ['nullable', 'integer'],
+            'html' => ['required', 'string'],
+            'content_ref' => ['string'],
+        ],
+        'update' => [
+            'html' => ['string'],
+            'archived' => ['boolean'],
+        ]
+    ];
+
+    public function __construct(
+        protected CommentRepo $commentRepo,
+        protected PageQueries $pageQueries,
+    ) {
+    }
+
+    /**
+     * Get a listing of comments visible to the user.
+     */
+    public function list(): JsonResponse
+    {
+        $query = $this->commentRepo->getQueryForVisible();
+
+        return $this->apiListingResponse($query, [
+            'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
+        ]);
+    }
+
+    /**
+     * Create a new comment on a page.
+     * If commenting as a reply to an existing comment, the 'reply_to' parameter
+     * should be provided, set to the 'local_id' of the comment being replied to.
+     */
+    public function create(Request $request): JsonResponse
+    {
+        $this->checkPermission(Permission::CommentCreateAll);
+
+        $input = $this->validate($request, $this->rules()['create']);
+        $page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
+
+        $comment = $this->commentRepo->create(
+            $page,
+            $input['html'],
+            $input['reply_to'] ?? null,
+            $input['content_ref'] ?? '',
+        );
+
+        return response()->json($comment);
+    }
+
+    /**
+     * Read the details of a single comment, along with its direct replies.
+     */
+    public function read(string $id): JsonResponse
+    {
+        $comment = $this->commentRepo->getVisibleById(intval($id));
+        $comment->load('createdBy', 'updatedBy');
+
+        $replies = $this->commentRepo->getQueryForVisible()
+            ->where('parent_id', '=', $comment->local_id)
+            ->where('commentable_id', '=', $comment->commentable_id)
+            ->where('commentable_type', '=', $comment->commentable_type)
+            ->get();
+
+        /** @var Comment[] $toProcess */
+        $toProcess = [$comment, ...$replies];
+        foreach ($toProcess as $commentToProcess) {
+            $commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
+            $commentToProcess->makeVisible('html');
+        }
+
+        $comment->setRelation('replies', $replies);
+
+        return response()->json($comment);
+    }
+
+
+    /**
+     * Update the content or archived status of an existing comment.
+     *
+     * Only provide a new archived status if needing to actively change the archive state.
+     * Only top-level comments (non-replies) can be archived or unarchived.
+     */
+    public function update(Request $request, string $id): JsonResponse
+    {
+        $comment = $this->commentRepo->getVisibleById(intval($id));
+        $this->checkOwnablePermission(Permission::CommentUpdate, $comment);
+
+        $input = $this->validate($request, $this->rules()['update']);
+        $hasHtml = isset($input['html']);
+
+        if (isset($input['archived'])) {
+            if ($input['archived']) {
+                $this->commentRepo->archive($comment, !$hasHtml);
+            } else {
+                $this->commentRepo->unarchive($comment, !$hasHtml);
+            }
+        }
+
+        if ($hasHtml) {
+            $comment = $this->commentRepo->update($comment, $input['html']);
+        }
+
+        return response()->json($comment);
+    }
+
+    /**
+     * Delete a single comment from the system.
+     */
+    public function delete(string $id): Response
+    {
+        $comment = $this->commentRepo->getVisibleById(intval($id));
+        $this->checkOwnablePermission(Permission::CommentDelete, $comment);
+
+        $this->commentRepo->delete($comment);
+
+        return response('', 204);
+    }
+}
diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php
index 52ccc8238..f61a2c8df 100644
--- a/app/Activity/Controllers/CommentController.php
+++ b/app/Activity/Controllers/CommentController.php
@@ -3,8 +3,11 @@
 namespace BookStack\Activity\Controllers;
 
 use BookStack\Activity\CommentRepo;
+use BookStack\Activity\Tools\CommentTree;
+use BookStack\Activity\Tools\CommentTreeNode;
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 
@@ -19,13 +22,14 @@ class CommentController extends Controller
     /**
      * Save a new comment for a Page.
      *
-     * @throws ValidationException
+     * @throws ValidationException|\Exception
      */
     public function savePageComment(Request $request, int $pageId)
     {
         $input = $this->validate($request, [
             'html'      => ['required', 'string'],
             'parent_id' => ['nullable', 'integer'],
+            'content_ref' => ['string'],
         ]);
 
         $page = $this->pageQueries->findVisibleById($pageId);
@@ -33,21 +37,14 @@ class CommentController extends Controller
             return response('Not found', 404);
         }
 
-        // Prevent adding comments to draft pages
-        if ($page->draft) {
-            return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
-        }
-
         // Create a new comment.
-        $this->checkPermission('comment-create-all');
-        $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
+        $this->checkPermission(Permission::CommentCreateAll);
+        $contentRef = $input['content_ref'] ?? '';
+        $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
 
         return view('comments.comment-branch', [
             'readOnly' => false,
-            'branch' => [
-                'comment' => $comment,
-                'children' => [],
-            ]
+            'branch' => new CommentTreeNode($comment, 0, []),
         ]);
     }
 
@@ -63,8 +60,8 @@ class CommentController extends Controller
         ]);
 
         $comment = $this->commentRepo->getById($commentId);
-        $this->checkOwnablePermission('page-view', $comment->entity);
-        $this->checkOwnablePermission('comment-update', $comment);
+        $this->checkOwnablePermission(Permission::PageView, $comment->entity);
+        $this->checkOwnablePermission(Permission::CommentUpdate, $comment);
 
         $comment = $this->commentRepo->update($comment, $input['html']);
 
@@ -74,13 +71,53 @@ class CommentController extends Controller
         ]);
     }
 
+    /**
+     * Mark a comment as archived.
+     */
+    public function archive(int $id)
+    {
+        $comment = $this->commentRepo->getById($id);
+        $this->checkOwnablePermission(Permission::PageView, $comment->entity);
+        if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
+            $this->showPermissionError();
+        }
+
+        $this->commentRepo->archive($comment);
+
+        $tree = new CommentTree($comment->entity);
+        return view('comments.comment-branch', [
+            'readOnly' => false,
+            'branch' => $tree->getCommentNodeForId($id),
+        ]);
+    }
+
+    /**
+     * Unmark a comment as archived.
+     */
+    public function unarchive(int $id)
+    {
+        $comment = $this->commentRepo->getById($id);
+        $this->checkOwnablePermission(Permission::PageView, $comment->entity);
+        if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
+            $this->showPermissionError();
+        }
+
+        $this->commentRepo->unarchive($comment);
+
+        $tree = new CommentTree($comment->entity);
+        return view('comments.comment-branch', [
+            'readOnly' => false,
+            'branch' => $tree->getCommentNodeForId($id),
+        ]);
+    }
+
     /**
      * Delete a comment from the system.
      */
     public function destroy(int $id)
     {
         $comment = $this->commentRepo->getById($id);
-        $this->checkOwnablePermission('comment-delete', $comment);
+        $this->checkOwnablePermission(Permission::CommentDelete, $comment);
 
         $this->commentRepo->delete($comment);
 
diff --git a/app/Activity/Controllers/WatchController.php b/app/Activity/Controllers/WatchController.php
index 5df75da39..b77a893ea 100644
--- a/app/Activity/Controllers/WatchController.php
+++ b/app/Activity/Controllers/WatchController.php
@@ -5,13 +5,14 @@ namespace BookStack\Activity\Controllers;
 use BookStack\Activity\Tools\UserEntityWatchOptions;
 use BookStack\Entities\Tools\MixedEntityRequestHelper;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use Illuminate\Http\Request;
 
 class WatchController extends Controller
 {
     public function update(Request $request, MixedEntityRequestHelper $entityHelper)
     {
-        $this->checkPermission('receive-notifications');
+        $this->checkPermission(Permission::ReceiveNotifications);
         $this->preventGuestAccess();
 
         $requestData = $this->validate($request, array_merge([
diff --git a/app/Activity/Controllers/WebhookController.php b/app/Activity/Controllers/WebhookController.php
index dcca1dc41..6a65b8363 100644
--- a/app/Activity/Controllers/WebhookController.php
+++ b/app/Activity/Controllers/WebhookController.php
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
 use BookStack\Activity\Models\Webhook;
 use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
 
@@ -14,7 +15,7 @@ class WebhookController extends Controller
     public function __construct()
     {
         $this->middleware([
-            'can:settings-manage',
+            Permission::SettingsManage->middleware()
         ]);
     }
 
diff --git a/app/Activity/Models/Activity.php b/app/Activity/Models/Activity.php
index ac9fec517..898a6c93a 100644
--- a/app/Activity/Models/Activity.php
+++ b/app/Activity/Models/Activity.php
@@ -6,6 +6,7 @@ use BookStack\App\Model;
 use BookStack\Entities\Models\Entity;
 use BookStack\Permissions\Models\JointPermission;
 use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -24,6 +25,8 @@ use Illuminate\Support\Str;
  */
 class Activity extends Model
 {
+    use HasFactory;
+
     /**
      * Get the loggable model related to this activity.
      * Currently only used for entities (previously entity_[id/type] columns).
diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php
index d0385d396..4d6c7fa41 100644
--- a/app/Activity/Models/Comment.php
+++ b/app/Activity/Models/Comment.php
@@ -3,46 +3,56 @@
 namespace BookStack\Activity\Models;
 
 use BookStack\App\Model;
+use BookStack\Permissions\Models\JointPermission;
+use BookStack\Permissions\PermissionApplicator;
 use BookStack\Users\Models\HasCreatorAndUpdater;
+use BookStack\Users\Models\OwnableInterface;
 use BookStack\Util\HtmlContentFilter;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
 
 /**
  * @property int      $id
- * @property string   $text - Deprecated & now unused (#4821)
  * @property string   $html
  * @property int|null $parent_id  - Relates to local_id, not id
  * @property int      $local_id
- * @property string   $entity_type
- * @property int      $entity_id
- * @property int      $created_by
- * @property int      $updated_by
+ * @property string   $commentable_type
+ * @property int      $commentable_id
+ * @property string   $content_ref
+ * @property bool     $archived
  */
-class Comment extends Model implements Loggable
+class Comment extends Model implements Loggable, OwnableInterface
 {
     use HasFactory;
     use HasCreatorAndUpdater;
 
     protected $fillable = ['parent_id'];
+    protected $hidden = ['html'];
+
+    protected $casts = [
+        'archived' => 'boolean',
+    ];
 
     /**
      * Get the entity that this comment belongs to.
      */
     public function entity(): MorphTo
     {
-        return $this->morphTo('entity');
+        return $this->morphTo('commentable');
     }
 
     /**
      * Get the parent comment this is in reply to (if existing).
+     * @return BelongsTo<Comment, $this>
      */
     public function parent(): BelongsTo
     {
         return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
-            ->where('entity_type', '=', $this->entity_type)
-            ->where('entity_id', '=', $this->entity_id);
+            ->where('commentable_type', '=', $this->commentable_type)
+            ->where('commentable_id', '=', $this->commentable_id);
     }
 
     /**
@@ -55,11 +65,27 @@ class Comment extends Model implements Loggable
 
     public function logDescriptor(): string
     {
-        return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
+        return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})";
     }
 
     public function safeHtml(): string
     {
         return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
     }
+
+    public function jointPermissions(): HasMany
+    {
+        return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
+            ->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
+    }
+
+    /**
+     * Scope the query to just the comments visible to the user based upon the
+     * user visibility of what has been commented on.
+     */
+    public function scopeVisible(Builder $query): Builder
+    {
+        return app()->make(PermissionApplicator::class)
+            ->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
+    }
 }
diff --git a/app/Activity/Models/Favourite.php b/app/Activity/Models/Favourite.php
index 6f6079b07..6b5e97dee 100644
--- a/app/Activity/Models/Favourite.php
+++ b/app/Activity/Models/Favourite.php
@@ -4,11 +4,14 @@ namespace BookStack\Activity\Models;
 
 use BookStack\App\Model;
 use BookStack\Permissions\Models\JointPermission;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
 
 class Favourite extends Model
 {
+    use HasFactory;
+
     protected $fillable = ['user_id'];
 
     /**
diff --git a/app/Activity/Models/Tag.php b/app/Activity/Models/Tag.php
index 0af0a65ac..0e7c68a27 100644
--- a/app/Activity/Models/Tag.php
+++ b/app/Activity/Models/Tag.php
@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
  * @property int    $id
  * @property string $name
  * @property string $value
+ * @property int    $entity_id
+ * @property string $entity_type
  * @property int    $order
  */
 class Tag extends Model
diff --git a/app/Activity/Models/Watch.php b/app/Activity/Models/Watch.php
index dfb72cc0a..b088bd724 100644
--- a/app/Activity/Models/Watch.php
+++ b/app/Activity/Models/Watch.php
@@ -5,6 +5,7 @@ namespace BookStack\Activity\Models;
 use BookStack\Activity\WatchLevels;
 use BookStack\Permissions\Models\JointPermission;
 use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -20,6 +21,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
  */
 class Watch extends Model
 {
+    use HasFactory;
+
     protected $guarded = [];
 
     public function watchable(): MorphTo
diff --git a/app/Activity/Notifications/Handlers/BaseNotificationHandler.php b/app/Activity/Notifications/Handlers/BaseNotificationHandler.php
index 3a9b0c1dc..bc6f2e22f 100644
--- a/app/Activity/Notifications/Handlers/BaseNotificationHandler.php
+++ b/app/Activity/Notifications/Handlers/BaseNotificationHandler.php
@@ -5,6 +5,7 @@ namespace BookStack\Activity\Notifications\Handlers;
 use BookStack\Activity\Models\Loggable;
 use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
 use BookStack\Entities\Models\Entity;
+use BookStack\Permissions\Permission;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Users\Models\User;
 use Illuminate\Support\Facades\Log;
@@ -26,7 +27,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
             }
 
             // Prevent sending of the user does not have notification permissions
-            if (!$user->can('receive-notifications')) {
+            if (!$user->can(Permission::ReceiveNotifications)) {
                 continue;
             }
 
diff --git a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php
index bc12c8566..daacfba56 100644
--- a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php
+++ b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php
@@ -27,7 +27,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
         $watcherIds = $watchers->getWatcherUserIds();
 
         // Page owner if user preferences allow
-        if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
+        if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
             $userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
             if ($userNotificationPrefs->notifyOnOwnPageComments()) {
                 $watcherIds[] = $page->owned_by;
@@ -36,7 +36,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
 
         // Parent comment creator if preferences allow
         $parentComment = $detail->parent()->first();
-        if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
+        if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
             $parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
             if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
                 $watcherIds[] = $parentComment->created_by;
diff --git a/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php
index 744aba18f..c9489d70e 100644
--- a/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php
+++ b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php
@@ -20,7 +20,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
             throw new \InvalidArgumentException("Detail for page update notifications must be a page");
         }
 
-        // Get last update from activity
+        // Get the last update from activity
+        /** @var ?Activity $lastUpdate */
         $lastUpdate = $detail->activity()
             ->where('type', '=', ActivityType::PAGE_UPDATE)
             ->where('id', '!=', $activity->id)
@@ -38,8 +39,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
         $watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
         $watcherIds = $watchers->getWatcherUserIds();
 
-        // Add page owner if preferences allow
-        if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
+        // Add the page owner if preferences allow
+        if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
             $userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
             if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
                 $watcherIds[] = $detail->owned_by;
diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php
index 16f6804ea..68f4a94d3 100644
--- a/app/Activity/Tools/CommentTree.php
+++ b/app/Activity/Tools/CommentTree.php
@@ -4,14 +4,20 @@ namespace BookStack\Activity\Tools;
 
 use BookStack\Activity\Models\Comment;
 use BookStack\Entities\Models\Page;
+use BookStack\Permissions\Permission;
 
 class CommentTree
 {
     /**
      * The built nested tree structure array.
-     * @var array{comment: Comment, depth: int, children: array}[]
+     * @var CommentTreeNode[]
      */
     protected array $tree;
+
+    /**
+     * A linear array of loaded comments.
+     * @var Comment[]
+     */
     protected array $comments;
 
     public function __construct(
@@ -28,7 +34,7 @@ class CommentTree
 
     public function empty(): bool
     {
-        return count($this->tree) === 0;
+        return count($this->getActive()) === 0;
     }
 
     public function count(): int
@@ -36,15 +42,41 @@ class CommentTree
         return count($this->comments);
     }
 
-    public function get(): array
+    public function getActive(): array
     {
-        return $this->tree;
+        return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived));
+    }
+
+    public function activeThreadCount(): int
+    {
+        return count($this->getActive());
+    }
+
+    public function getArchived(): array
+    {
+        return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived));
+    }
+
+    public function archivedThreadCount(): int
+    {
+        return count($this->getArchived());
+    }
+
+    public function getCommentNodeForId(int $commentId): ?CommentTreeNode
+    {
+        foreach ($this->tree as $node) {
+            if ($node->comment->id === $commentId) {
+                return $node;
+            }
+        }
+
+        return null;
     }
 
     public function canUpdateAny(): bool
     {
         foreach ($this->comments as $comment) {
-            if (userCan('comment-update', $comment)) {
+            if (userCan(Permission::CommentUpdate, $comment)) {
                 return true;
             }
         }
@@ -52,8 +84,17 @@ class CommentTree
         return false;
     }
 
+    public function loadVisibleHtml(): void
+    {
+        foreach ($this->comments as $comment) {
+            $comment->setAttribute('html', $comment->safeHtml());
+            $comment->makeVisible('html');
+        }
+    }
+
     /**
      * @param Comment[] $comments
+     * @return CommentTreeNode[]
      */
     protected function createTree(array $comments): array
     {
@@ -77,28 +118,27 @@ class CommentTree
 
         $tree = [];
         foreach ($childMap[0] ?? [] as $childId) {
-            $tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
+            $tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
         }
 
         return $tree;
     }
 
-    protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
+    protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
     {
         $childIds = $childMap[$id] ?? [];
         $children = [];
 
         foreach ($childIds as $childId) {
-            $children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
+            $children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
         }
 
-        return [
-            'comment' => $byId[$id],
-            'depth' => $depth,
-            'children' => $children,
-        ];
+        return new CommentTreeNode($byId[$id], $depth, $children);
     }
 
+    /**
+     * @return Comment[]
+     */
     protected function loadComments(): array
     {
         if (!$this->enabled()) {
diff --git a/app/Activity/Tools/CommentTreeNode.php b/app/Activity/Tools/CommentTreeNode.php
new file mode 100644
index 000000000..7b280bd2d
--- /dev/null
+++ b/app/Activity/Tools/CommentTreeNode.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace BookStack\Activity\Tools;
+
+use BookStack\Activity\Models\Comment;
+
+class CommentTreeNode
+{
+    public Comment $comment;
+    public int $depth;
+
+    /**
+     * @var CommentTreeNode[]
+     */
+    public array $children;
+
+    public function __construct(Comment $comment, int $depth, array $children)
+    {
+        $this->comment = $comment;
+        $this->depth = $depth;
+        $this->children = $children;
+    }
+}
diff --git a/app/Activity/Tools/TagClassGenerator.php b/app/Activity/Tools/TagClassGenerator.php
index 1a1bd16c8..0f7aa1fe0 100644
--- a/app/Activity/Tools/TagClassGenerator.php
+++ b/app/Activity/Tools/TagClassGenerator.php
@@ -3,17 +3,16 @@
 namespace BookStack\Activity\Tools;
 
 use BookStack\Activity\Models\Tag;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Permissions\Permission;
 
 class TagClassGenerator
 {
-    protected array $tags;
-
-    /**
-     * @param Tag[] $tags
-     */
-    public function __construct(array $tags)
-    {
-        $this->tags = $tags;
+    public function __construct(
+        protected Entity $entity
+    ) {
     }
 
     /**
@@ -22,14 +21,23 @@ class TagClassGenerator
     public function generate(): array
     {
         $classes = [];
+        $tags = $this->entity->tags->all();
+
+        foreach ($tags as $tag) {
+             array_push($classes, ...$this->generateClassesForTag($tag));
+        }
+
+        if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) {
+            $bookTags = $this->entity->book->tags;
+            foreach ($bookTags as $bookTag) {
+                 array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
+            }
+        }
 
-        foreach ($this->tags as $tag) {
-            $name = $this->normalizeTagClassString($tag->name);
-            $value = $this->normalizeTagClassString($tag->value);
-            $classes[] = 'tag-name-' . $name;
-            if ($value) {
-                $classes[] = 'tag-value-' . $value;
-                $classes[] = 'tag-pair-' . $name . '-' . $value;
+        if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) {
+            $chapterTags = $this->entity->chapter->tags;
+            foreach ($chapterTags as $chapterTag) {
+                 array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-'));
             }
         }
 
@@ -41,6 +49,22 @@ class TagClassGenerator
         return implode(' ', $this->generate());
     }
 
+    /**
+     * @return string[]
+     */
+    protected function generateClassesForTag(Tag $tag, string $prefix = ''): array
+    {
+        $classes = [];
+        $name = $this->normalizeTagClassString($tag->name);
+        $value = $this->normalizeTagClassString($tag->value);
+        $classes[] = "{$prefix}tag-name-{$name}";
+        if ($value) {
+            $classes[] = "{$prefix}tag-value-{$value}";
+            $classes[] = "{$prefix}tag-pair-{$name}-{$value}";
+        }
+        return $classes;
+    }
+
     protected function normalizeTagClassString(string $value): string
     {
         $value = str_replace(' ', '', strtolower($value));
diff --git a/app/Activity/Tools/UserEntityWatchOptions.php b/app/Activity/Tools/UserEntityWatchOptions.php
index 559d7903d..8e5f70758 100644
--- a/app/Activity/Tools/UserEntityWatchOptions.php
+++ b/app/Activity/Tools/UserEntityWatchOptions.php
@@ -7,6 +7,7 @@ use BookStack\Activity\WatchLevels;
 use BookStack\Entities\Models\BookChild;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
+use BookStack\Permissions\Permission;
 use BookStack\Users\Models\User;
 use Illuminate\Database\Eloquent\Builder;
 
@@ -22,7 +23,7 @@ class UserEntityWatchOptions
 
     public function canWatch(): bool
     {
-        return $this->user->can('receive-notifications') && !$this->user->isGuest();
+        return $this->user->can(Permission::ReceiveNotifications) && !$this->user->isGuest();
     }
 
     public function getWatchLevel(): string
diff --git a/app/Activity/Tools/WebhookFormatter.php b/app/Activity/Tools/WebhookFormatter.php
index 6ccb084b8..cb4e9cb0a 100644
--- a/app/Activity/Tools/WebhookFormatter.php
+++ b/app/Activity/Tools/WebhookFormatter.php
@@ -50,7 +50,7 @@ class WebhookFormatter
         }
 
         if ($this->detail instanceof Model) {
-            $data['related_item'] = $this->formatModel();
+            $data['related_item'] = $this->formatModel($this->detail);
         }
 
         return $data;
@@ -83,10 +83,8 @@ class WebhookFormatter
         );
     }
 
-    protected function formatModel(): array
+    protected function formatModel(Model $model): array
     {
-        /** @var Model $model */
-        $model = $this->detail;
         $model->unsetRelations();
 
         foreach ($this->modelFormatters as $formatter) {
diff --git a/app/Activity/WatchLevels.php b/app/Activity/WatchLevels.php
index de3c5e122..edbece2d3 100644
--- a/app/Activity/WatchLevels.php
+++ b/app/Activity/WatchLevels.php
@@ -36,7 +36,7 @@ class WatchLevels
 
     /**
      * Get all the possible values as an option_name => value array.
-     * @returns array<string, int>
+     * @return array<string, int>
      */
     public static function all(): array
     {
@@ -50,7 +50,7 @@ class WatchLevels
 
     /**
      * Get the watch options suited for the given entity.
-     * @returns array<string, int>
+     * @return array<string, int>
      */
     public static function allSuitedFor(Entity $entity): array
     {
diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php
index 9cae80617..eb8f5508c 100644
--- a/app/Api/ApiDocsGenerator.php
+++ b/app/Api/ApiDocsGenerator.php
@@ -2,6 +2,7 @@
 
 namespace BookStack\Api;
 
+use BookStack\App\AppVersion;
 use BookStack\Http\ApiController;
 use Exception;
 use Illuminate\Contracts\Container\BindingResolutionException;
@@ -25,7 +26,7 @@ class ApiDocsGenerator
      */
     public static function generateConsideringCache(): Collection
     {
-        $appVersion = trim(file_get_contents(base_path('version')));
+        $appVersion = AppVersion::get();
         $cacheKey = 'api-docs::' . $appVersion;
         $isProduction = config('app.env') === 'production';
         $cacheVal = $isProduction ? Cache::get($cacheKey) : null;
@@ -82,11 +83,19 @@ class ApiDocsGenerator
     protected function loadDetailsFromControllers(Collection $routes): Collection
     {
         return $routes->map(function (array $route) {
+            $class = $this->getReflectionClass($route['controller']);
             $method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
             $comment = $method->getDocComment();
-            $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
+            $route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null;
             $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
 
+            // Load class description for the model
+            // Not ideal to have it here on each route, but adding it in a more structured manner would break
+            // docs resulting JSON format and therefore be an API break.
+            // Save refactoring for a more significant set of changes.
+            $classComment = $class->getDocComment();
+            $route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null;
+
             return $route;
         });
     }
@@ -139,7 +148,7 @@ class ApiDocsGenerator
     /**
      * Parse out the description text from a class method comment.
      */
-    protected function parseDescriptionFromMethodComment(string $comment): string
+    protected function parseDescriptionFromDocBlockComment(string $comment): string
     {
         $matches = [];
         preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
@@ -154,6 +163,16 @@ class ApiDocsGenerator
      * @throws ReflectionException
      */
     protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
+    {
+        return $this->getReflectionClass($className)->getMethod($methodName);
+    }
+
+    /**
+     * Get a reflection class from the given class name.
+     *
+     * @throws ReflectionException
+     */
+    protected function getReflectionClass(string $className): ReflectionClass
     {
         $class = $this->reflectionClasses[$className] ?? null;
         if ($class === null) {
@@ -161,7 +180,7 @@ class ApiDocsGenerator
             $this->reflectionClasses[$className] = $class;
         }
 
-        return $class->getMethod($methodName);
+        return $class;
     }
 
     /**
diff --git a/app/Api/ApiTokenGuard.php b/app/Api/ApiTokenGuard.php
index 6302884a9..9f4537b29 100644
--- a/app/Api/ApiTokenGuard.php
+++ b/app/Api/ApiTokenGuard.php
@@ -4,6 +4,7 @@ namespace BookStack\Api;
 
 use BookStack\Access\LoginService;
 use BookStack\Exceptions\ApiAuthException;
+use BookStack\Permissions\Permission;
 use Illuminate\Auth\GuardHelpers;
 use Illuminate\Contracts\Auth\Authenticatable;
 use Illuminate\Contracts\Auth\Guard;
@@ -146,7 +147,7 @@ class ApiTokenGuard implements Guard
             throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
         }
 
-        if (!$token->user->can('access-api')) {
+        if (!$token->user->can(Permission::AccessApi)) {
             throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
         }
     }
diff --git a/app/Api/UserApiTokenController.php b/app/Api/UserApiTokenController.php
index 3606e8260..2ca9e2235 100644
--- a/app/Api/UserApiTokenController.php
+++ b/app/Api/UserApiTokenController.php
@@ -4,6 +4,7 @@ namespace BookStack\Api;
 
 use BookStack\Activity\ActivityType;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Users\Models\User;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Hash;
@@ -16,8 +17,8 @@ class UserApiTokenController extends Controller
      */
     public function create(Request $request, int $userId)
     {
-        $this->checkPermission('access-api');
-        $this->checkPermissionOrCurrentUser('users-manage', $userId);
+        $this->checkPermission(Permission::AccessApi);
+        $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
         $this->updateContext($request);
 
         $user = User::query()->findOrFail($userId);
@@ -35,8 +36,8 @@ class UserApiTokenController extends Controller
      */
     public function store(Request $request, int $userId)
     {
-        $this->checkPermission('access-api');
-        $this->checkPermissionOrCurrentUser('users-manage', $userId);
+        $this->checkPermission(Permission::AccessApi);
+        $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
 
         $this->validate($request, [
             'name'       => ['required', 'max:250'],
@@ -143,8 +144,8 @@ class UserApiTokenController extends Controller
      */
     protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array
     {
-        $this->checkPermissionOr('users-manage', function () use ($userId) {
-            return $userId === user()->id && userCan('access-api');
+        $this->checkPermissionOr(Permission::UsersManage, function () use ($userId) {
+            return $userId === user()->id && userCan(Permission::AccessApi);
         });
 
         $user = User::query()->findOrFail($userId);
diff --git a/app/App/AppVersion.php b/app/App/AppVersion.php
new file mode 100644
index 000000000..af422f641
--- /dev/null
+++ b/app/App/AppVersion.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace BookStack\App;
+
+class AppVersion
+{
+    protected static string $version = '';
+
+    /**
+     * Get the application's version number from its top-level `version` text file.
+     */
+    public static function get(): string
+    {
+        if (!empty(static::$version)) {
+            return static::$version;
+        }
+
+        $versionFile = base_path('version');
+        $version = trim(file_get_contents($versionFile));
+        static::$version = $version;
+
+        return $version;
+    }
+}
diff --git a/app/App/HomeController.php b/app/App/HomeController.php
index 0585e0af5..7ec8b0af2 100644
--- a/app/App/HomeController.php
+++ b/app/App/HomeController.php
@@ -83,7 +83,7 @@ class HomeController extends Controller
         if ($homepageOption === 'bookshelves') {
             $shelves = $this->queries->shelves->visibleForListWithCover()
                 ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
-                ->paginate(18);
+                ->paginate(intval(setting('sorting-shelves-per-page', '18')));
             $data = array_merge($commonData, ['shelves' => $shelves]);
 
             return view('home.shelves', $data);
@@ -92,7 +92,7 @@ class HomeController extends Controller
         if ($homepageOption === 'books') {
             $books = $this->queries->books->visibleForListWithCover()
                 ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
-                ->paginate(18);
+                ->paginate(intval(setting('sorting-books-per-page', '18')));
             $data = array_merge($commonData, ['books' => $books]);
 
             return view('home.books', $data);
diff --git a/app/App/Model.php b/app/App/Model.php
index 8de5a2762..e1c7511c1 100644
--- a/app/App/Model.php
+++ b/app/App/Model.php
@@ -8,7 +8,7 @@ class Model extends EloquentModel
 {
     /**
      * Provides public access to get the raw attribute value from the model.
-     * Used in areas where no mutations are required but performance is critical.
+     * Used in areas where no mutations are required, but performance is critical.
      *
      * @return mixed
      */
diff --git a/app/App/Providers/AuthServiceProvider.php b/app/App/Providers/AuthServiceProvider.php
index 23c339079..6a8162521 100644
--- a/app/App/Providers/AuthServiceProvider.php
+++ b/app/App/Providers/AuthServiceProvider.php
@@ -59,8 +59,8 @@ class AuthServiceProvider extends ServiceProvider
      */
     public function register(): void
     {
-        Auth::provider('external-users', function ($app, array $config) {
-            return new ExternalBaseUserProvider($config['model']);
+        Auth::provider('external-users', function () {
+            return new ExternalBaseUserProvider();
         });
 
         // Bind and provide the default system user as a singleton to the app instance when needed.
diff --git a/app/App/Providers/EventServiceProvider.php b/app/App/Providers/EventServiceProvider.php
index 34ab7cfef..60e78efe0 100644
--- a/app/App/Providers/EventServiceProvider.php
+++ b/app/App/Providers/EventServiceProvider.php
@@ -15,7 +15,7 @@ class EventServiceProvider extends ServiceProvider
     /**
      * The event listener mappings for the application.
      *
-     * @var array<class-string, array<int, class-string>>
+     * @var array<class-string, array<int, string>>
      */
     protected $listen = [
         SocialiteWasCalled::class => [
diff --git a/app/App/Providers/ViewTweaksServiceProvider.php b/app/App/Providers/ViewTweaksServiceProvider.php
index 7115dcb51..6771e513f 100644
--- a/app/App/Providers/ViewTweaksServiceProvider.php
+++ b/app/App/Providers/ViewTweaksServiceProvider.php
@@ -3,6 +3,7 @@
 namespace BookStack\App\Providers;
 
 use BookStack\Entities\BreadcrumbsViewComposer;
+use BookStack\Util\DateFormatter;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Support\Facades\Blade;
 use Illuminate\Support\Facades\View;
@@ -10,6 +11,15 @@ use Illuminate\Support\ServiceProvider;
 
 class ViewTweaksServiceProvider extends ServiceProvider
 {
+    public function register()
+    {
+        $this->app->singleton(DateFormatter::class, function ($app) {
+            return new DateFormatter(
+                $app['config']->get('app.display_timezone'),
+            );
+        });
+    }
+
     /**
      * Bootstrap services.
      */
@@ -21,6 +31,9 @@ class ViewTweaksServiceProvider extends ServiceProvider
         // View Composers
         View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
 
+        // View Globals
+        View::share('dates', $this->app->make(DateFormatter::class));
+
         // Custom blade view directives
         Blade::directive('icon', function ($expression) {
             return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";
diff --git a/app/App/Sluggable.php b/app/App/Sluggable.php
deleted file mode 100644
index f8da2e265..000000000
--- a/app/App/Sluggable.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-
-namespace BookStack\App;
-
-/**
- * Assigned to models that can have slugs.
- * Must have the below properties.
- *
- * @property int    $id
- * @property string $name
- */
-interface Sluggable
-{
-    /**
-     * Regenerate the slug for this model.
-     */
-    public function refreshSlug(): string;
-}
diff --git a/app/App/SluggableInterface.php b/app/App/SluggableInterface.php
new file mode 100644
index 000000000..dd544f5ed
--- /dev/null
+++ b/app/App/SluggableInterface.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace BookStack\App;
+
+/**
+ * Assigned to models that can have slugs.
+ * Must have the below properties.
+ *
+ * @property string $slug
+ */
+interface SluggableInterface
+{
+}
diff --git a/app/App/SystemApiController.php b/app/App/SystemApiController.php
new file mode 100644
index 000000000..c476a74e9
--- /dev/null
+++ b/app/App/SystemApiController.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace BookStack\App;
+
+use BookStack\Http\ApiController;
+use Illuminate\Http\JsonResponse;
+
+class SystemApiController extends ApiController
+{
+    /**
+     * Read details regarding the BookStack instance.
+     * Some details may be null where not set, like the app logo for example.
+     */
+    public function read(): JsonResponse
+    {
+        $logoSetting = setting('app-logo', '');
+        if ($logoSetting === 'none') {
+            $logo = null;
+        } else {
+            $logo = $logoSetting ? url($logoSetting) : url('/logo.png');
+        }
+
+        return response()->json([
+            'version' => AppVersion::get(),
+            'instance_id' => setting('instance-id'),
+            'app_name' => setting('app-name'),
+            'app_logo' => $logo,
+            'base_url' => url('/'),
+        ]);
+    }
+}
diff --git a/app/App/helpers.php b/app/App/helpers.php
index eec86553f..0e357e36a 100644
--- a/app/App/helpers.php
+++ b/app/App/helpers.php
@@ -1,7 +1,9 @@
 <?php
 
+use BookStack\App\AppVersion;
 use BookStack\App\Model;
 use BookStack\Facades\Theme;
+use BookStack\Permissions\Permission;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Settings\SettingService;
 use BookStack\Users\Models\User;
@@ -13,12 +15,7 @@ use BookStack\Users\Models\User;
  */
 function versioned_asset(string $file = ''): string
 {
-    static $version = null;
-
-    if (is_null($version)) {
-        $versionFile = base_path('version');
-        $version = trim(file_get_contents($versionFile));
-    }
+    $version = AppVersion::get();
 
     $additional = '';
     if (config('app.env') === 'development') {
@@ -43,7 +40,7 @@ function user(): User
  * Check if the current user has a permission. If an ownable element
  * is passed in the jointPermissions are checked against that particular item.
  */
-function userCan(string $permission, ?Model $ownable = null): bool
+function userCan(string|Permission $permission, ?Model $ownable = null): bool
 {
     if (is_null($ownable)) {
         return user()->can($permission);
@@ -59,7 +56,7 @@ function userCan(string $permission, ?Model $ownable = null): bool
  * Check if the current user can perform the given action on any items in the system.
  * Can be provided the class name of an entity to filter ability to that specific entity type.
  */
-function userCanOnAny(string $action, string $entityClass = ''): bool
+function userCanOnAny(string|Permission $action, string $entityClass = ''): bool
 {
     $permissions = app()->make(PermissionApplicator::class);
 
diff --git a/app/Config/app.php b/app/Config/app.php
index b96d0bdb7..40e542d3e 100644
--- a/app/Config/app.php
+++ b/app/Config/app.php
@@ -70,8 +70,8 @@ return [
     // A list of the sources/hostnames that can be reached by application SSR calls.
     // This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
     // Host-specific functionality (usually controlled via other options) like auth
-    // or user avatars for example, won't use this list.
-    // Space seperated if multiple. Can use '*' as a wildcard.
+    // or user avatars, for example, won't use this list.
+    // Space separated if multiple. Can use '*' as a wildcard.
     // Values will be compared prefix-matched, case-insensitive, against called SSR urls.
     // Defaults to allow all hosts.
     'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
@@ -80,8 +80,10 @@ return [
     // Integer value between 0 (IP hidden) to 4 (Full IP usage)
     'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
 
-    // Application timezone for back-end date functions.
+    // Application timezone for stored date/time values.
     'timezone' => env('APP_TIMEZONE', 'UTC'),
+    // Application timezone for displayed date/time values in the UI.
+    'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')),
 
     // Default locale to use
     // A default variant is also stored since Laravel can overwrite
diff --git a/app/Config/cache.php b/app/Config/cache.php
index 9a0be8eab..01c822a65 100644
--- a/app/Config/cache.php
+++ b/app/Config/cache.php
@@ -85,6 +85,6 @@ return [
     |
     */
 
-    'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'),
+    'prefix' => env('CACHE_PREFIX', 'bookstack_cache_'),
 
 ];
diff --git a/app/Config/database.php b/app/Config/database.php
index 8d38a86df..5edafa777 100644
--- a/app/Config/database.php
+++ b/app/Config/database.php
@@ -75,7 +75,7 @@ return [
             'collation'      => 'utf8mb4_unicode_ci',
             // Prefixes are only semi-supported and may be unstable
             // since they are not tested as part of our automated test suite.
-            // If used, the prefix should not be changed otherwise you will likely receive errors.
+            // If used, the prefix should not be changed; otherwise you will likely receive errors.
             'prefix'         => env('DB_TABLE_PREFIX', ''),
             'prefix_indexes' => true,
             'strict'         => false,
@@ -103,9 +103,7 @@ return [
     ],
 
     // Migration Repository Table
-    // This table keeps track of all the migrations that have already run for
-    // your application. Using this information, we can determine which of
-    // the migrations on disk haven't actually been run in the database.
+    // This table keeps track of all the migrations that have already run for the application.
     'migrations' => 'migrations',
 
     // Redis configuration to use if set
diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php
index ab507a2f8..facf5f2df 100644
--- a/app/Config/filesystems.php
+++ b/app/Config/filesystems.php
@@ -11,7 +11,7 @@
 return [
 
     // Default Filesystem Disk
-    // Options: local, local_secure, s3
+    // Options: local, local_secure, local_secure_restricted, s3
     'default' => env('STORAGE_TYPE', 'local'),
 
     // Filesystem to use specifically for image uploads.
@@ -34,6 +34,7 @@ return [
             'root'       => public_path(),
             'serve'      => false,
             'throw'      => true,
+            'directory_visibility' => 'public',
         ],
 
         'local_secure_attachments' => [
diff --git a/app/Config/mail.php b/app/Config/mail.php
index 038864f8c..7256ce884 100644
--- a/app/Config/mail.php
+++ b/app/Config/mail.php
@@ -11,6 +11,7 @@
 // Configured mail encryption method.
 // STARTTLS should still be attempted, but tls/ssl forces TLS usage.
 $mailEncryption = env('MAIL_ENCRYPTION', null);
+$mailPort = intval(env('MAIL_PORT', 587));
 
 return [
 
@@ -33,13 +34,13 @@ return [
             'transport' => 'smtp',
             'scheme' => null,
             'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
-            'port' => env('MAIL_PORT', 587),
+            'port' => $mailPort,
             'username' => env('MAIL_USERNAME'),
             'password' => env('MAIL_PASSWORD'),
             'verify_peer' => env('MAIL_VERIFY_SSL', true),
             'timeout' => null,
             'local_domain' => null,
-            'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
+            'require_tls' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl' || $mailPort === 465),
         ],
 
         'sendmail' => [
diff --git a/app/Config/oidc.php b/app/Config/oidc.php
index 8b5470931..16bec873c 100644
--- a/app/Config/oidc.php
+++ b/app/Config/oidc.php
@@ -47,6 +47,12 @@ return [
     // Multiple values can be provided comma seperated.
     'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
 
+    // Enable fetching of the user's avatar from the 'picture' claim on login.
+    // Will only be fetched if the user doesn't already have an avatar image assigned.
+    // This can be a security risk due to performing server-side fetching (with up to 3 redirects) of
+    // data from external URLs. Only enable if you trust the OIDC auth provider to provide safe URLs for user images.
+    'fetch_avatar' => env('OIDC_FETCH_AVATAR', false),
+
     // Group sync options
     // Enable syncing, upon login, of OIDC groups to BookStack roles
     'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
diff --git a/app/Console/Commands/CreateAdminCommand.php b/app/Console/Commands/CreateAdminCommand.php
index 82b6e50aa..bf72553f7 100644
--- a/app/Console/Commands/CreateAdminCommand.php
+++ b/app/Console/Commands/CreateAdminCommand.php
@@ -8,7 +8,6 @@ use Illuminate\Console\Command;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Str;
 use Illuminate\Validation\Rules\Password;
-use Illuminate\Validation\Rules\Unique;
 
 class CreateAdminCommand extends Command
 {
@@ -21,7 +20,9 @@ class CreateAdminCommand extends Command
                             {--email= : The email address for the new admin user}
                             {--name= : The name of the new admin user}
                             {--password= : The password to assign to the new admin user}
-                            {--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}';
+                            {--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}
+                            {--generate-password : Generate a random password for the new admin user}
+                            {--initial : Indicate if this should set/update the details of the initial admin user}';
 
     /**
      * The console command description.
@@ -35,26 +36,12 @@ class CreateAdminCommand extends Command
      */
     public function handle(UserRepo $userRepo): int
     {
-        $details = $this->snakeCaseOptions();
-
-        if (empty($details['email'])) {
-            $details['email'] = $this->ask('Please specify an email address for the new admin user');
-        }
-
-        if (empty($details['name'])) {
-            $details['name'] = $this->ask('Please specify a name for the new admin user');
-        }
-
-        if (empty($details['password'])) {
-            if (empty($details['external_auth_id'])) {
-                $details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
-            } else {
-                $details['password'] = Str::random(32);
-            }
-        }
+        $initialAdminOnly = $this->option('initial');
+        $shouldGeneratePassword = $this->option('generate-password');
+        $details = $this->gatherDetails($shouldGeneratePassword, $initialAdminOnly);
 
         $validator = Validator::make($details, [
-            'email'            => ['required', 'email', 'min:5', new Unique('users', 'email')],
+            'email'            => ['required', 'email', 'min:5'],
             'name'             => ['required', 'min:2'],
             'password'         => ['required_without:external_auth_id', Password::default()],
             'external_auth_id' => ['required_without:password'],
@@ -68,16 +55,101 @@ class CreateAdminCommand extends Command
             return 1;
         }
 
+        $adminRole = Role::getSystemRole('admin');
+
+        if ($initialAdminOnly) {
+            $handled = $this->handleInitialAdminIfExists($userRepo, $details, $shouldGeneratePassword, $adminRole);
+            if ($handled !== null) {
+                return $handled;
+            }
+        }
+
+        $emailUsed = $userRepo->getByEmail($details['email']) !== null;
+        if ($emailUsed) {
+            $this->error("Could not create admin account.");
+            $this->error("An account with the email address \"{$details['email']}\" already exists.");
+            return 1;
+        }
+
         $user = $userRepo->createWithoutActivity($validator->validated());
-        $user->attachRole(Role::getSystemRole('admin'));
+        $user->attachRole($adminRole);
         $user->email_confirmed = true;
         $user->save();
 
-        $this->info("Admin account with email \"{$user->email}\" successfully created!");
+        if ($shouldGeneratePassword) {
+            $this->line($details['password']);
+        } else {
+            $this->info("Admin account with email \"{$user->email}\" successfully created!");
+        }
 
         return 0;
     }
 
+    /**
+     * Handle updates to the original admin account if it exists.
+     * Returns an int return status if handled, otherwise returns null if not handled (new user to be created).
+     */
+    protected function handleInitialAdminIfExists(UserRepo $userRepo, array $data, bool $generatePassword, Role $adminRole): int|null
+    {
+        $defaultAdmin = $userRepo->getByEmail('admin@admin.com');
+        if ($defaultAdmin && $defaultAdmin->hasSystemRole('admin')) {
+            if ($defaultAdmin->email !== $data['email'] && $userRepo->getByEmail($data['email']) !== null) {
+                $this->error("Could not create admin account.");
+                $this->error("An account with the email address \"{$data['email']}\" already exists.");
+                return 1;
+            }
+
+            $userRepo->updateWithoutActivity($defaultAdmin, $data, true);
+            if ($generatePassword) {
+                $this->line($data['password']);
+            } else {
+                $this->info("The default admin user has been updated with the provided details!");
+            }
+
+            return 0;
+        } else if ($adminRole->users()->count() > 0) {
+            $this->warn('Non-default admin user already exists. Skipping creation of new admin user.');
+            return 2;
+        }
+
+        return null;
+    }
+
+    protected function gatherDetails(bool $generatePassword, bool $initialAdmin): array
+    {
+        $details = $this->snakeCaseOptions();
+
+        if (empty($details['email'])) {
+            if ($initialAdmin) {
+                $details['email'] = 'admin@example.com';
+            } else {
+                $details['email'] = $this->ask('Please specify an email address for the new admin user');
+            }
+        }
+
+        if (empty($details['name'])) {
+            if ($initialAdmin) {
+                $details['name'] = 'Admin';
+            } else {
+                $details['name'] = $this->ask('Please specify a name for the new admin user');
+            }
+        }
+
+        if (empty($details['password'])) {
+            if (empty($details['external_auth_id'])) {
+                if ($generatePassword) {
+                    $details['password'] = Str::random(32);
+                } else {
+                    $details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
+                }
+            } else {
+                $details['password'] = Str::random(32);
+            }
+        }
+
+        return $details;
+    }
+
     protected function snakeCaseOptions(): array
     {
         $returnOpts = [];
diff --git a/app/Console/Commands/UpdateUrlCommand.php b/app/Console/Commands/UpdateUrlCommand.php
index e155878d3..fd86e0706 100644
--- a/app/Console/Commands/UpdateUrlCommand.php
+++ b/app/Console/Commands/UpdateUrlCommand.php
@@ -45,14 +45,12 @@ class UpdateUrlCommand extends Command
 
         $columnsToUpdateByTable = [
             'attachments' => ['path'],
-            'pages'       => ['html', 'text', 'markdown'],
-            'chapters'    => ['description_html'],
-            'books'       => ['description_html'],
-            'bookshelves' => ['description_html'],
+            'entity_page_data' => ['html', 'text', 'markdown'],
+            'entity_container_data' => ['description_html'],
             'page_revisions' => ['html', 'text', 'markdown'],
             'images'      => ['url'],
             'settings'    => ['value'],
-            'comments'    => ['html', 'text'],
+            'comments'    => ['html'],
         ];
 
         foreach ($columnsToUpdateByTable as $table => $columns) {
diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php
index a617ee2da..325f0583c 100644
--- a/app/Entities/Controllers/BookApiController.php
+++ b/app/Entities/Controllers/BookApiController.php
@@ -11,6 +11,7 @@ use BookStack\Entities\Queries\PageQueries;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 
@@ -47,7 +48,7 @@ class BookApiController extends ApiController
      */
     public function create(Request $request)
     {
-        $this->checkPermission('book-create-all');
+        $this->checkPermission(Permission::BookCreateAll);
         $requestData = $this->validate($request, $this->rules()['create']);
 
         $book = $this->bookRepo->create($requestData);
@@ -57,7 +58,7 @@ class BookApiController extends ApiController
 
     /**
      * View the details of a single book.
-     * The response data will contain 'content' property listing the chapter and pages directly within, in
+     * The response data will contain a 'content' property listing the chapter and pages directly within, in
      * the same structure as you'd see within the BookStack interface when viewing a book. Top-level
      * contents will have a 'type' property to distinguish between pages & chapters.
      */
@@ -92,7 +93,7 @@ class BookApiController extends ApiController
     public function update(Request $request, string $id)
     {
         $book = $this->queries->findVisibleByIdOrFail(intval($id));
-        $this->checkOwnablePermission('book-update', $book);
+        $this->checkOwnablePermission(Permission::BookUpdate, $book);
 
         $requestData = $this->validate($request, $this->rules()['update']);
         $book = $this->bookRepo->update($book, $requestData);
@@ -109,7 +110,7 @@ class BookApiController extends ApiController
     public function delete(string $id)
     {
         $book = $this->queries->findVisibleByIdOrFail(intval($id));
-        $this->checkOwnablePermission('book-delete', $book);
+        $this->checkOwnablePermission(Permission::BookDelete, $book);
 
         $this->bookRepo->destroy($book);
 
@@ -121,9 +122,10 @@ class BookApiController extends ApiController
         $book = clone $book;
         $book->unsetRelations()->refresh();
 
-        $book->load(['tags', 'cover']);
-        $book->makeVisible('description_html')
-            ->setAttribute('description_html', $book->descriptionHtml());
+        $book->load(['tags']);
+        $book->makeVisible(['cover', 'description_html'])
+            ->setAttribute('description_html', $book->descriptionInfo()->getHtml())
+            ->setAttribute('cover', $book->coverInfo()->getImage());
 
         return $book;
     }
diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php
index b1685081a..1c8009246 100644
--- a/app/Entities/Controllers/BookController.php
+++ b/app/Entities/Controllers/BookController.php
@@ -8,6 +8,7 @@ use BookStack\Activity\Models\View;
 use BookStack\Activity\Tools\UserEntityWatchOptions;
 use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Queries\BookshelfQueries;
+use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Tools\Cloner;
@@ -17,7 +18,9 @@ use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Facades\Activity;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\References\ReferenceFetcher;
+use BookStack\Util\DatabaseTransaction;
 use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
@@ -29,6 +32,7 @@ class BookController extends Controller
         protected ShelfContext $shelfContext,
         protected BookRepo $bookRepo,
         protected BookQueries $queries,
+        protected EntityQueries $entityQueries,
         protected BookshelfQueries $shelfQueries,
         protected ReferenceFetcher $referenceFetcher,
     ) {
@@ -48,7 +52,7 @@ class BookController extends Controller
 
         $books = $this->queries->visibleForListWithCover()
             ->orderBy($listOptions->getSort(), $listOptions->getOrder())
-            ->paginate(18);
+            ->paginate(intval(setting('sorting-books-per-page', '18')));
         $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
         $popular = $this->queries->popularForList()->take(4)->get();
         $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
@@ -72,12 +76,12 @@ class BookController extends Controller
      */
     public function create(?string $shelfSlug = null)
     {
-        $this->checkPermission('book-create-all');
+        $this->checkPermission(Permission::BookCreateAll);
 
         $bookshelf = null;
         if ($shelfSlug !== null) {
             $bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
-            $this->checkOwnablePermission('bookshelf-update', $bookshelf);
+            $this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf);
         }
 
         $this->setPageTitle(trans('entities.books_create'));
@@ -95,7 +99,7 @@ class BookController extends Controller
      */
     public function store(Request $request, ?string $shelfSlug = null)
     {
-        $this->checkPermission('book-create-all');
+        $this->checkPermission(Permission::BookCreateAll);
         $validated = $this->validate($request, [
             'name'                => ['required', 'string', 'max:255'],
             'description_html'    => ['string', 'max:2000'],
@@ -107,7 +111,7 @@ class BookController extends Controller
         $bookshelf = null;
         if ($shelfSlug !== null) {
             $bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
-            $this->checkOwnablePermission('bookshelf-update', $bookshelf);
+            $this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf);
         }
 
         $book = $this->bookRepo->create($validated);
@@ -125,7 +129,16 @@ class BookController extends Controller
      */
     public function show(Request $request, ActivityQueries $activities, string $slug)
     {
-        $book = $this->queries->findVisibleBySlugOrFail($slug);
+        try {
+            $book = $this->queries->findVisibleBySlugOrFail($slug);
+        } catch (NotFoundException $exception) {
+            $book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
+            if (is_null($book)) {
+                throw $exception;
+            }
+            return redirect($book->getUrl());
+        }
+
         $bookChildren = (new BookContents($book))->getTree(true);
         $bookParentShelves = $book->shelves()->scopes('visible')->get();
 
@@ -153,7 +166,7 @@ class BookController extends Controller
     public function edit(string $slug)
     {
         $book = $this->queries->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('book-update', $book);
+        $this->checkOwnablePermission(Permission::BookUpdate, $book);
         $this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
 
         return view('books.edit', ['book' => $book, 'current' => $book]);
@@ -169,7 +182,7 @@ class BookController extends Controller
     public function update(Request $request, string $slug)
     {
         $book = $this->queries->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('book-update', $book);
+        $this->checkOwnablePermission(Permission::BookUpdate, $book);
 
         $validated = $this->validate($request, [
             'name'                => ['required', 'string', 'max:255'],
@@ -196,7 +209,7 @@ class BookController extends Controller
     public function showDelete(string $bookSlug)
     {
         $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
-        $this->checkOwnablePermission('book-delete', $book);
+        $this->checkOwnablePermission(Permission::BookDelete, $book);
         $this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
 
         return view('books.delete', ['book' => $book, 'current' => $book]);
@@ -210,7 +223,7 @@ class BookController extends Controller
     public function destroy(string $bookSlug)
     {
         $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
-        $this->checkOwnablePermission('book-delete', $book);
+        $this->checkOwnablePermission(Permission::BookDelete, $book);
 
         $this->bookRepo->destroy($book);
 
@@ -225,7 +238,7 @@ class BookController extends Controller
     public function showCopy(string $bookSlug)
     {
         $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
-        $this->checkOwnablePermission('book-view', $book);
+        $this->checkOwnablePermission(Permission::BookView, $book);
 
         session()->flashInput(['name' => $book->name]);
 
@@ -242,8 +255,8 @@ class BookController extends Controller
     public function copy(Request $request, Cloner $cloner, string $bookSlug)
     {
         $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
-        $this->checkOwnablePermission('book-view', $book);
-        $this->checkPermission('book-create-all');
+        $this->checkOwnablePermission(Permission::BookView, $book);
+        $this->checkPermission(Permission::BookCreateAll);
 
         $newName = $request->get('name') ?: $book->name;
         $bookCopy = $cloner->cloneBook($book, $newName);
@@ -258,12 +271,14 @@ class BookController extends Controller
     public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug)
     {
         $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
-        $this->checkOwnablePermission('book-update', $book);
-        $this->checkOwnablePermission('book-delete', $book);
-        $this->checkPermission('bookshelf-create-all');
-        $this->checkPermission('book-create-all');
-
-        $shelf = $transformer->transformBookToShelf($book);
+        $this->checkOwnablePermission(Permission::BookUpdate, $book);
+        $this->checkOwnablePermission(Permission::BookDelete, $book);
+        $this->checkPermission(Permission::BookshelfCreateAll);
+        $this->checkPermission(Permission::BookCreateAll);
+
+        $shelf = (new DatabaseTransaction(function () use ($book, $transformer) {
+            return $transformer->transformBookToShelf($book);
+        }))->run();
 
         return redirect($shelf->getUrl());
     }
diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php
index b512f2d05..735742060 100644
--- a/app/Entities/Controllers/BookshelfApiController.php
+++ b/app/Entities/Controllers/BookshelfApiController.php
@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Queries\BookshelfQueries;
 use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use Exception;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Http\Request;
@@ -45,7 +46,7 @@ class BookshelfApiController extends ApiController
      */
     public function create(Request $request)
     {
-        $this->checkPermission('bookshelf-create-all');
+        $this->checkPermission(Permission::BookshelfCreateAll);
         $requestData = $this->validate($request, $this->rules()['create']);
 
         $bookIds = $request->get('books', []);
@@ -84,7 +85,7 @@ class BookshelfApiController extends ApiController
     public function update(Request $request, string $id)
     {
         $shelf = $this->queries->findVisibleByIdOrFail(intval($id));
-        $this->checkOwnablePermission('bookshelf-update', $shelf);
+        $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
 
         $requestData = $this->validate($request, $this->rules()['update']);
         $bookIds = $request->get('books', null);
@@ -103,7 +104,7 @@ class BookshelfApiController extends ApiController
     public function delete(string $id)
     {
         $shelf = $this->queries->findVisibleByIdOrFail(intval($id));
-        $this->checkOwnablePermission('bookshelf-delete', $shelf);
+        $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf);
 
         $this->bookshelfRepo->destroy($shelf);
 
@@ -115,9 +116,10 @@ class BookshelfApiController extends ApiController
         $shelf = clone $shelf;
         $shelf->unsetRelations()->refresh();
 
-        $shelf->load(['tags', 'cover']);
-        $shelf->makeVisible('description_html')
-            ->setAttribute('description_html', $shelf->descriptionHtml());
+        $shelf->load(['tags']);
+        $shelf->makeVisible(['cover', 'description_html'])
+            ->setAttribute('description_html', $shelf->descriptionInfo()->getHtml())
+            ->setAttribute('cover', $shelf->coverInfo()->getImage());
 
         return $shelf;
     }
diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php
index 6cedd23e7..a7e1de159 100644
--- a/app/Entities/Controllers/BookshelfController.php
+++ b/app/Entities/Controllers/BookshelfController.php
@@ -6,11 +6,13 @@ use BookStack\Activity\ActivityQueries;
 use BookStack\Activity\Models\View;
 use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Queries\BookshelfQueries;
+use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Entities\Tools\ShelfContext;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\References\ReferenceFetcher;
 use BookStack\Util\SimpleListOptions;
 use Exception;
@@ -22,6 +24,7 @@ class BookshelfController extends Controller
     public function __construct(
         protected BookshelfRepo $shelfRepo,
         protected BookshelfQueries $queries,
+        protected EntityQueries $entityQueries,
         protected BookQueries $bookQueries,
         protected ShelfContext $shelfContext,
         protected ReferenceFetcher $referenceFetcher,
@@ -42,7 +45,7 @@ class BookshelfController extends Controller
 
         $shelves = $this->queries->visibleForListWithCover()
             ->orderBy($listOptions->getSort(), $listOptions->getOrder())
-            ->paginate(18);
+            ->paginate(intval(setting('sorting-shelves-per-page', '18')));
         $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
         $popular = $this->queries->popularForList()->get();
         $new = $this->queries->visibleForList()
@@ -68,7 +71,7 @@ class BookshelfController extends Controller
      */
     public function create()
     {
-        $this->checkPermission('bookshelf-create-all');
+        $this->checkPermission(Permission::BookshelfCreateAll);
         $books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
         $this->setPageTitle(trans('entities.shelves_create'));
 
@@ -83,7 +86,7 @@ class BookshelfController extends Controller
      */
     public function store(Request $request)
     {
-        $this->checkPermission('bookshelf-create-all');
+        $this->checkPermission(Permission::BookshelfCreateAll);
         $validated = $this->validate($request, [
             'name'             => ['required', 'string', 'max:255'],
             'description_html' => ['string', 'max:2000'],
@@ -104,8 +107,17 @@ class BookshelfController extends Controller
      */
     public function show(Request $request, ActivityQueries $activities, string $slug)
     {
-        $shelf = $this->queries->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('bookshelf-view', $shelf);
+        try {
+            $shelf = $this->queries->findVisibleBySlugOrFail($slug);
+        } catch (NotFoundException $exception) {
+            $shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
+            if (is_null($shelf)) {
+                throw $exception;
+            }
+            return redirect($shelf->getUrl());
+        }
+
+        $this->checkOwnablePermission(Permission::BookshelfView, $shelf);
 
         $listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
             'default' => trans('common.sort_default'),
@@ -115,6 +127,7 @@ class BookshelfController extends Controller
         ]);
 
         $sort = $listOptions->getSort();
+
         $sortedVisibleShelfBooks = $shelf->visibleBooks()
             ->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
             ->get()
@@ -143,7 +156,7 @@ class BookshelfController extends Controller
     public function edit(string $slug)
     {
         $shelf = $this->queries->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('bookshelf-update', $shelf);
+        $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
 
         $shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
         $books = $this->bookQueries->visibleForList()
@@ -169,7 +182,7 @@ class BookshelfController extends Controller
     public function update(Request $request, string $slug)
     {
         $shelf = $this->queries->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('bookshelf-update', $shelf);
+        $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
         $validated = $this->validate($request, [
             'name'             => ['required', 'string', 'max:255'],
             'description_html' => ['string', 'max:2000'],
@@ -195,7 +208,7 @@ class BookshelfController extends Controller
     public function showDelete(string $slug)
     {
         $shelf = $this->queries->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('bookshelf-delete', $shelf);
+        $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf);
 
         $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
 
@@ -210,7 +223,7 @@ class BookshelfController extends Controller
     public function destroy(string $slug)
     {
         $shelf = $this->queries->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('bookshelf-delete', $shelf);
+        $this->checkOwnablePermission(Permission::BookshelfDelete, $shelf);
 
         $this->shelfRepo->destroy($shelf);
 
diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php
index 430654330..6aa62f887 100644
--- a/app/Entities/Controllers/ChapterApiController.php
+++ b/app/Entities/Controllers/ChapterApiController.php
@@ -2,19 +2,20 @@
 
 namespace BookStack\Entities\Controllers;
 
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Queries\ChapterQueries;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Repos\ChapterRepo;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use Exception;
-use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Http\Request;
 
 class ChapterApiController extends ApiController
 {
-    protected $rules = [
+    protected array $rules = [
         'create' => [
             'book_id'             => ['required', 'integer'],
             'name'                => ['required', 'string', 'max:255'],
@@ -65,7 +66,7 @@ class ChapterApiController extends ApiController
 
         $bookId = $request->get('book_id');
         $book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
-        $this->checkOwnablePermission('chapter-create', $book);
+        $this->checkOwnablePermission(Permission::ChapterCreate, $book);
 
         $chapter = $this->chapterRepo->create($requestData, $book);
 
@@ -101,10 +102,10 @@ class ChapterApiController extends ApiController
     {
         $requestData = $this->validate($request, $this->rules()['update']);
         $chapter = $this->queries->findVisibleByIdOrFail(intval($id));
-        $this->checkOwnablePermission('chapter-update', $chapter);
+        $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
 
-        if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
-            $this->checkOwnablePermission('chapter-delete', $chapter);
+        if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) {
+            $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
 
             try {
                 $this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
@@ -129,7 +130,7 @@ class ChapterApiController extends ApiController
     public function delete(string $id)
     {
         $chapter = $this->queries->findVisibleByIdOrFail(intval($id));
-        $this->checkOwnablePermission('chapter-delete', $chapter);
+        $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
 
         $this->chapterRepo->destroy($chapter);
 
@@ -143,8 +144,11 @@ class ChapterApiController extends ApiController
 
         $chapter->load(['tags']);
         $chapter->makeVisible('description_html');
-        $chapter->setAttribute('description_html', $chapter->descriptionHtml());
-        $chapter->setAttribute('book_slug', $chapter->book()->first()->slug);
+        $chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml());
+
+        /** @var Book $book */
+        $book = $chapter->book()->first();
+        $chapter->setAttribute('book_slug', $book->slug);
 
         return $chapter;
     }
diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php
index 4274589e2..878ee42b5 100644
--- a/app/Entities/Controllers/ChapterController.php
+++ b/app/Entities/Controllers/ChapterController.php
@@ -17,7 +17,9 @@ use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\References\ReferenceFetcher;
+use BookStack\Util\DatabaseTransaction;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
@@ -38,7 +40,7 @@ class ChapterController extends Controller
     public function create(string $bookSlug)
     {
         $book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
-        $this->checkOwnablePermission('chapter-create', $book);
+        $this->checkOwnablePermission(Permission::ChapterCreate, $book);
 
         $this->setPageTitle(trans('entities.chapters_create'));
 
@@ -63,7 +65,7 @@ class ChapterController extends Controller
         ]);
 
         $book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
-        $this->checkOwnablePermission('chapter-create', $book);
+        $this->checkOwnablePermission(Permission::ChapterCreate, $book);
 
         $chapter = $this->chapterRepo->create($validated, $book);
 
@@ -75,8 +77,15 @@ class ChapterController extends Controller
      */
     public function show(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('chapter-view', $chapter);
+        try {
+            $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
+        } catch (NotFoundException $exception) {
+            $chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
+            if (is_null($chapter)) {
+                throw $exception;
+            }
+            return redirect($chapter->getUrl());
+        }
 
         $sidebarTree = (new BookContents($chapter->book))->getTree();
         $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
@@ -105,7 +114,7 @@ class ChapterController extends Controller
     public function edit(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('chapter-update', $chapter);
+        $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
 
         $this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
 
@@ -127,9 +136,9 @@ class ChapterController extends Controller
         ]);
 
         $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('chapter-update', $chapter);
+        $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
 
-        $this->chapterRepo->update($chapter, $validated);
+        $chapter = $this->chapterRepo->update($chapter, $validated);
 
         return redirect($chapter->getUrl());
     }
@@ -142,7 +151,7 @@ class ChapterController extends Controller
     public function showDelete(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('chapter-delete', $chapter);
+        $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
 
         $this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
 
@@ -158,7 +167,7 @@ class ChapterController extends Controller
     public function destroy(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('chapter-delete', $chapter);
+        $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
 
         $this->chapterRepo->destroy($chapter);
 
@@ -174,8 +183,8 @@ class ChapterController extends Controller
     {
         $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
         $this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()]));
-        $this->checkOwnablePermission('chapter-update', $chapter);
-        $this->checkOwnablePermission('chapter-delete', $chapter);
+        $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
+        $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
 
         return view('chapters.move', [
             'chapter' => $chapter,
@@ -191,8 +200,8 @@ class ChapterController extends Controller
     public function move(Request $request, string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('chapter-update', $chapter);
-        $this->checkOwnablePermission('chapter-delete', $chapter);
+        $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
+        $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
 
         $entitySelection = $request->get('entity_selection', null);
         if ($entitySelection === null || $entitySelection === '') {
@@ -220,7 +229,6 @@ class ChapterController extends Controller
     public function showCopy(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('chapter-view', $chapter);
 
         session()->flashInput(['name' => $chapter->name]);
 
@@ -239,7 +247,6 @@ class ChapterController extends Controller
     public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('chapter-view', $chapter);
 
         $entitySelection = $request->get('entity_selection') ?: null;
         $newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
@@ -250,7 +257,7 @@ class ChapterController extends Controller
             return redirect($chapter->getUrl('/copy'));
         }
 
-        $this->checkOwnablePermission('chapter-create', $newParentBook);
+        $this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook);
 
         $newName = $request->get('name') ?: $chapter->name;
         $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
@@ -265,11 +272,13 @@ class ChapterController extends Controller
     public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('chapter-update', $chapter);
-        $this->checkOwnablePermission('chapter-delete', $chapter);
-        $this->checkPermission('book-create-all');
+        $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
+        $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
+        $this->checkPermission(Permission::BookCreateAll);
 
-        $book = $transformer->transformChapterToBook($chapter);
+        $book = (new DatabaseTransaction(function () use ($chapter, $transformer) {
+            return $transformer->transformChapterToBook($chapter);
+        }))->run();
 
         return redirect($book->getUrl());
     }
diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php
index 40598e209..197018cca 100644
--- a/app/Entities/Controllers/PageApiController.php
+++ b/app/Entities/Controllers/PageApiController.php
@@ -2,17 +2,19 @@
 
 namespace BookStack\Entities\Controllers;
 
+use BookStack\Activity\Tools\CommentTree;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use Exception;
 use Illuminate\Http\Request;
 
 class PageApiController extends ApiController
 {
-    protected $rules = [
+    protected array $rules = [
         'create' => [
             'book_id'    => ['required_without:chapter_id', 'integer'],
             'chapter_id' => ['required_without:book_id', 'integer'],
@@ -76,7 +78,7 @@ class PageApiController extends ApiController
         } else {
             $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
         }
-        $this->checkOwnablePermission('page-create', $parent);
+        $this->checkOwnablePermission(Permission::PageCreate, $parent);
 
         $draft = $this->pageRepo->getNewDraftPage($parent);
         $this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create'])));
@@ -87,21 +89,32 @@ class PageApiController extends ApiController
     /**
      * View the details of a single page.
      * Pages will always have HTML content. They may have markdown content
-     * if the markdown editor was used to last update the page.
+     * if the Markdown editor was used to last update the page.
      *
-     * The 'html' property is the fully rendered & escaped HTML content that BookStack
+     * The 'html' property is the fully rendered and escaped HTML content that BookStack
      * would show on page view, with page includes handled.
      * The 'raw_html' property is the direct database stored HTML content, which would be
      * what BookStack shows on page edit.
      *
      * See the "Content Security" section of these docs for security considerations when using
      * the page content returned from this endpoint.
+     *
+     * Comments for the page are provided in a tree-structure representing the hierarchy of top-level
+     * comments and replies, for both archived and active comments.
      */
     public function read(string $id)
     {
         $page = $this->queries->findVisibleByIdOrFail($id);
 
-        return response()->json($page->forJsonDisplay());
+        $page = $page->forJsonDisplay();
+        $commentTree = (new CommentTree($page));
+        $commentTree->loadVisibleHtml();
+        $page->setAttribute('comments', [
+            'active' => $commentTree->getActive(),
+            'archived' => $commentTree->getArchived(),
+        ]);
+
+        return response()->json($page);
     }
 
     /**
@@ -116,7 +129,7 @@ class PageApiController extends ApiController
         $requestData = $this->validate($request, $this->rules['update']);
 
         $page = $this->queries->findVisibleByIdOrFail($id);
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
 
         $parent = null;
         if ($request->has('chapter_id')) {
@@ -126,7 +139,7 @@ class PageApiController extends ApiController
         }
 
         if ($parent && !$parent->matches($page->getParent())) {
-            $this->checkOwnablePermission('page-delete', $page);
+            $this->checkOwnablePermission(Permission::PageDelete, $page);
 
             try {
                 $this->pageRepo->move($page, $parent->getType() . ':' . $parent->id);
@@ -151,7 +164,7 @@ class PageApiController extends ApiController
     public function delete(string $id)
     {
         $page = $this->queries->findVisibleByIdOrFail($id);
-        $this->checkOwnablePermission('page-delete', $page);
+        $this->checkOwnablePermission(Permission::PageDelete, $page);
 
         $this->pageRepo->destroy($page);
 
diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php
index 230a84721..a648bc298 100644
--- a/app/Entities/Controllers/PageController.php
+++ b/app/Entities/Controllers/PageController.php
@@ -19,6 +19,7 @@ use BookStack\Entities\Tools\PageEditorData;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\References\ReferenceFetcher;
 use Exception;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -49,7 +50,7 @@ class PageController extends Controller
             $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
         }
 
-        $this->checkOwnablePermission('page-create', $parent);
+        $this->checkOwnablePermission(Permission::PageCreate, $parent);
 
         // Redirect to draft edit screen if signed in
         if ($this->isSignedIn()) {
@@ -81,7 +82,7 @@ class PageController extends Controller
             $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
         }
 
-        $this->checkOwnablePermission('page-create', $parent);
+        $this->checkOwnablePermission(Permission::PageCreate, $parent);
 
         $page = $this->pageRepo->getNewDraftPage($parent);
         $this->pageRepo->publishDraft($page, [
@@ -99,7 +100,7 @@ class PageController extends Controller
     public function editDraft(Request $request, string $bookSlug, int $pageId)
     {
         $draft = $this->queries->findVisibleByIdOrFail($pageId);
-        $this->checkOwnablePermission('page-create', $draft->getParent());
+        $this->checkOwnablePermission(Permission::PageCreate, $draft->getParent());
 
         $editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', ''));
         $this->setPageTitle(trans('entities.pages_edit_draft'));
@@ -118,8 +119,9 @@ class PageController extends Controller
         $this->validate($request, [
             'name' => ['required', 'string', 'max:255'],
         ]);
+
         $draftPage = $this->queries->findVisibleByIdOrFail($pageId);
-        $this->checkOwnablePermission('page-create', $draftPage->getParent());
+        $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
 
         $page = $this->pageRepo->publishDraft($draftPage, $request->all());
 
@@ -137,9 +139,7 @@ class PageController extends Controller
         try {
             $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
         } catch (NotFoundException $e) {
-            $revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
-            $page = $revision->page ?? null;
-
+            $page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
             if (is_null($page)) {
                 throw $e;
             }
@@ -147,8 +147,6 @@ class PageController extends Controller
             return redirect($page->getUrl());
         }
 
-        $this->checkOwnablePermission('page-view', $page);
-
         $pageContent = (new PageContent($page));
         $page->html = $pageContent->render();
         $pageNav = $pageContent->getNavigation($page->html);
@@ -196,7 +194,7 @@ class PageController extends Controller
     public function edit(Request $request, string $bookSlug, string $pageSlug)
     {
         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page, $page->getUrl());
 
         $editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', ''));
         if ($editorData->getWarnings()) {
@@ -220,7 +218,7 @@ class PageController extends Controller
             'name' => ['required', 'string', 'max:255'],
         ]);
         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
 
         $this->pageRepo->update($page, $request->all());
 
@@ -235,7 +233,7 @@ class PageController extends Controller
     public function saveDraft(Request $request, int $pageId)
     {
         $page = $this->queries->findVisibleByIdOrFail($pageId);
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
 
         if (!$this->isSignedIn()) {
             return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500);
@@ -272,7 +270,7 @@ class PageController extends Controller
     public function showDelete(string $bookSlug, string $pageSlug)
     {
         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-delete', $page);
+        $this->checkOwnablePermission(Permission::PageDelete, $page);
         $this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
         $usedAsTemplate =
             $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
@@ -294,7 +292,7 @@ class PageController extends Controller
     public function showDeleteDraft(string $bookSlug, int $pageId)
     {
         $page = $this->queries->findVisibleByIdOrFail($pageId);
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
         $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
         $usedAsTemplate =
             $this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
@@ -317,7 +315,7 @@ class PageController extends Controller
     public function destroy(string $bookSlug, string $pageSlug)
     {
         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-delete', $page);
+        $this->checkOwnablePermission(Permission::PageDelete, $page);
         $parent = $page->getParent();
 
         $this->pageRepo->destroy($page);
@@ -336,13 +334,13 @@ class PageController extends Controller
         $page = $this->queries->findVisibleByIdOrFail($pageId);
         $book = $page->book;
         $chapter = $page->chapter;
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
 
         $this->pageRepo->destroy($page);
 
         $this->showSuccessNotification(trans('entities.pages_delete_draft_success'));
 
-        if ($chapter && userCan('view', $chapter)) {
+        if ($chapter && userCan(Permission::ChapterView, $chapter)) {
             return redirect($chapter->getUrl());
         }
 
@@ -383,8 +381,8 @@ class PageController extends Controller
     public function showMove(string $bookSlug, string $pageSlug)
     {
         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-update', $page);
-        $this->checkOwnablePermission('page-delete', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
+        $this->checkOwnablePermission(Permission::PageDelete, $page);
 
         return view('pages.move', [
             'book' => $page->book,
@@ -401,8 +399,8 @@ class PageController extends Controller
     public function move(Request $request, string $bookSlug, string $pageSlug)
     {
         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-update', $page);
-        $this->checkOwnablePermission('page-delete', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
+        $this->checkOwnablePermission(Permission::PageDelete, $page);
 
         $entitySelection = $request->get('entity_selection', null);
         if ($entitySelection === null || $entitySelection === '') {
@@ -430,7 +428,6 @@ class PageController extends Controller
     public function showCopy(string $bookSlug, string $pageSlug)
     {
         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-view', $page);
         session()->flashInput(['name' => $page->name]);
 
         return view('pages.copy', [
@@ -448,7 +445,7 @@ class PageController extends Controller
     public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
     {
         $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-view', $page);
+        $this->checkOwnablePermission(Permission::PageView, $page);
 
         $entitySelection = $request->get('entity_selection') ?: null;
         $newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
@@ -459,7 +456,7 @@ class PageController extends Controller
             return redirect($page->getUrl('/copy'));
         }
 
-        $this->checkOwnablePermission('page-create', $newParent);
+        $this->checkOwnablePermission(Permission::PageCreate, $newParent);
 
         $newName = $request->get('name') ?: $page->name;
         $pageCopy = $cloner->clonePage($page, $newParent, $newName);
diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php
index 4985c39f3..35f1e8daf 100644
--- a/app/Entities/Controllers/PageRevisionController.php
+++ b/app/Entities/Controllers/PageRevisionController.php
@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\PageContent;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Facades\Activity;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
 use Ssddanbrown\HtmlDiff\Diff;
@@ -98,7 +99,7 @@ class PageRevisionController extends Controller
             throw new NotFoundException();
         }
 
-        $prev = $revision->getPrevious();
+        $prev = $revision->getPreviousRevision();
         $prevContent = $prev->html ?? '';
         $diff = Diff::excecute($prevContent, $revision->html);
 
@@ -124,7 +125,7 @@ class PageRevisionController extends Controller
     public function restore(string $bookSlug, string $pageSlug, int $revisionId)
     {
         $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
 
         $page = $this->pageRepo->restoreRevision($page, $revisionId);
 
@@ -139,7 +140,7 @@ class PageRevisionController extends Controller
     public function destroy(string $bookSlug, string $pageSlug, int $revId)
     {
         $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('page-delete', $page);
+        $this->checkOwnablePermission(Permission::PageDelete, $page);
 
         $revision = $page->revisions()->where('id', '=', $revId)->first();
         if ($revision === null) {
diff --git a/app/Entities/Controllers/RecycleBinApiController.php b/app/Entities/Controllers/RecycleBinApiController.php
index bf22d7dcd..614685136 100644
--- a/app/Entities/Controllers/RecycleBinApiController.php
+++ b/app/Entities/Controllers/RecycleBinApiController.php
@@ -6,18 +6,20 @@ use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\BookChild;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\DeletionRepo;
 use BookStack\Http\ApiController;
-use Closure;
+use BookStack\Permissions\Permission;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 
 class RecycleBinApiController extends ApiController
 {
     public function __construct()
     {
         $this->middleware(function ($request, $next) {
-            $this->checkPermission('settings-manage');
-            $this->checkPermission('restrictions-manage-all');
+            $this->checkPermission(Permission::SettingsManage);
+            $this->checkPermission(Permission::RestrictionsManageAll);
 
             return $next($request);
         });
@@ -40,7 +42,7 @@ class RecycleBinApiController extends ApiController
             'updated_at',
             'deletable_type',
             'deletable_id',
-        ], [Closure::fromCallable([$this, 'listFormatter'])]);
+        ], [$this->listFormatter(...)]);
     }
 
     /**
@@ -69,10 +71,9 @@ class RecycleBinApiController extends ApiController
     /**
      * Load some related details for the deletion listing.
      */
-    protected function listFormatter(Deletion $deletion)
+    protected function listFormatter(Deletion $deletion): void
     {
         $deletable = $deletion->deletable;
-        $withTrashedQuery = fn (Builder $query) => $query->withTrashed();
 
         if ($deletable instanceof BookChild) {
             $parent = $deletable->getParent();
@@ -81,11 +82,19 @@ class RecycleBinApiController extends ApiController
         }
 
         if ($deletable instanceof Book || $deletable instanceof Chapter) {
-            $countsToLoad = ['pages' => $withTrashedQuery];
+            $countsToLoad = ['pages' => static::withTrashedQuery(...)];
             if ($deletable instanceof Book) {
-                $countsToLoad['chapters'] = $withTrashedQuery;
+                $countsToLoad['chapters'] = static::withTrashedQuery(...);
             }
             $deletable->loadCount($countsToLoad);
         }
     }
+
+    /**
+     * @param Builder<Chapter|Page> $query
+     */
+    protected static function withTrashedQuery(Builder $query): void
+    {
+        $query->withTrashed();
+    }
 }
diff --git a/app/Entities/Controllers/RecycleBinController.php b/app/Entities/Controllers/RecycleBinController.php
index d11dde4dd..f3c2b6a01 100644
--- a/app/Entities/Controllers/RecycleBinController.php
+++ b/app/Entities/Controllers/RecycleBinController.php
@@ -8,6 +8,7 @@ use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Repos\DeletionRepo;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 
 class RecycleBinController extends Controller
 {
@@ -20,8 +21,8 @@ class RecycleBinController extends Controller
     public function __construct()
     {
         $this->middleware(function ($request, $next) {
-            $this->checkPermission('settings-manage');
-            $this->checkPermission('restrictions-manage-all');
+            $this->checkPermission(Permission::SettingsManage);
+            $this->checkPermission(Permission::RestrictionsManageAll);
 
             return $next($request);
         });
diff --git a/app/Entities/EntityExistsRule.php b/app/Entities/EntityExistsRule.php
new file mode 100644
index 000000000..da2105446
--- /dev/null
+++ b/app/Entities/EntityExistsRule.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace BookStack\Entities;
+
+use Illuminate\Validation\Rules\Exists;
+
+class EntityExistsRule implements \Stringable
+{
+    public function __construct(
+        protected string $type,
+    ) {
+    }
+
+    public function __toString()
+    {
+        $existsRule = (new Exists('entities', 'id'))
+            ->where('type', $this->type);
+        return $existsRule->__toString();
+    }
+}
diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php
index ede4fc7d5..1909dbd56 100644
--- a/app/Entities/Models/Book.php
+++ b/app/Entities/Models/Book.php
@@ -2,9 +2,10 @@
 
 namespace BookStack\Entities\Models;
 
+use BookStack\Entities\Tools\EntityCover;
+use BookStack\Entities\Tools\EntityDefaultTemplate;
 use BookStack\Sorting\SortRule;
 use BookStack\Uploads\Image;
-use Exception;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -15,26 +16,25 @@ use Illuminate\Support\Collection;
  * Class Book.
  *
  * @property string                                   $description
+ * @property string                                   $description_html
  * @property int                                      $image_id
  * @property ?int                                     $default_template_id
  * @property ?int                                     $sort_rule_id
- * @property Image|null                               $cover
  * @property \Illuminate\Database\Eloquent\Collection $chapters
  * @property \Illuminate\Database\Eloquent\Collection $pages
  * @property \Illuminate\Database\Eloquent\Collection $directPages
  * @property \Illuminate\Database\Eloquent\Collection $shelves
- * @property ?Page                                    $defaultTemplate
- * @property ?SortRule                                 $sortRule
+ * @property ?SortRule                                $sortRule
  */
-class Book extends Entity implements HasCoverImage
+class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
 {
     use HasFactory;
-    use HasHtmlDescription;
+    use ContainerTrait;
 
     public float $searchFactor = 1.2;
 
+    protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority'];
     protected $fillable = ['name'];
-    protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
 
     /**
      * Get the url for this book.
@@ -44,57 +44,9 @@ class Book extends Entity implements HasCoverImage
         return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
     }
 
-    /**
-     * Returns book cover image, if book cover not exists return default cover image.
-     */
-    public function getBookCover(int $width = 440, int $height = 250): string
-    {
-        $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
-        if (!$this->image_id || !$this->cover) {
-            return $default;
-        }
-
-        try {
-            return $this->cover->getThumb($width, $height, false) ?? $default;
-        } catch (Exception $err) {
-            return $default;
-        }
-    }
-
-    /**
-     * Get the cover image of the book.
-     */
-    public function cover(): BelongsTo
-    {
-        return $this->belongsTo(Image::class, 'image_id');
-    }
-
-    /**
-     * Get the type of the image model that is used when storing a cover image.
-     */
-    public function coverImageTypeKey(): string
-    {
-        return 'cover_book';
-    }
-
-    /**
-     * Get the Page that is used as default template for newly created pages within this Book.
-     */
-    public function defaultTemplate(): BelongsTo
-    {
-        return $this->belongsTo(Page::class, 'default_template_id');
-    }
-
-    /**
-     * Get the sort set assigned to this book, if existing.
-     */
-    public function sortRule(): BelongsTo
-    {
-        return $this->belongsTo(SortRule::class);
-    }
-
     /**
      * Get all pages within this book.
+     * @return HasMany<Page, $this>
      */
     public function pages(): HasMany
     {
@@ -106,11 +58,12 @@ class Book extends Entity implements HasCoverImage
      */
     public function directPages(): HasMany
     {
-        return $this->pages()->where('chapter_id', '=', '0');
+        return $this->pages()->whereNull('chapter_id');
     }
 
     /**
      * Get all chapters within this book.
+     * @return HasMany<Chapter, $this>
      */
     public function chapters(): HasMany
     {
@@ -135,4 +88,27 @@ class Book extends Entity implements HasCoverImage
 
         return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
     }
+
+    public function defaultTemplate(): EntityDefaultTemplate
+    {
+        return new EntityDefaultTemplate($this);
+    }
+
+    public function cover(): BelongsTo
+    {
+        return $this->belongsTo(Image::class, 'image_id');
+    }
+
+    public function coverInfo(): EntityCover
+    {
+        return new EntityCover($this);
+    }
+
+    /**
+     * Get the sort rule assigned to this container, if existing.
+     */
+    public function sortRule(): BelongsTo
+    {
+        return $this->belongsTo(SortRule::class);
+    }
 }
diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php
index ad54fb926..9a8493c3a 100644
--- a/app/Entities/Models/BookChild.php
+++ b/app/Entities/Models/BookChild.php
@@ -2,8 +2,6 @@
 
 namespace BookStack\Entities\Models;
 
-use BookStack\References\ReferenceUpdater;
-use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
@@ -18,34 +16,10 @@ abstract class BookChild extends Entity
 {
     /**
      * Get the book this page sits in.
+     * @return BelongsTo<Book, $this>
      */
     public function book(): BelongsTo
     {
         return $this->belongsTo(Book::class)->withTrashed();
     }
-
-    /**
-     * Change the book that this entity belongs to.
-     */
-    public function changeBook(int $newBookId): Entity
-    {
-        $oldUrl = $this->getUrl();
-        $this->book_id = $newBookId;
-        $this->refreshSlug();
-        $this->save();
-        $this->refresh();
-
-        if ($oldUrl !== $this->getUrl()) {
-            app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
-        }
-
-        // Update all child pages if a chapter
-        if ($this instanceof Chapter) {
-            foreach ($this->pages()->withTrashed()->get() as $page) {
-                $page->changeBook($newBookId);
-            }
-        }
-
-        return $this;
-    }
 }
diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php
index 9ffa0ea9c..42dcc8f8f 100644
--- a/app/Entities/Models/Bookshelf.php
+++ b/app/Entities/Models/Bookshelf.php
@@ -2,34 +2,34 @@
 
 namespace BookStack\Entities\Models;
 
+use BookStack\Entities\Tools\EntityCover;
 use BookStack\Uploads\Image;
-use Exception;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
-class Bookshelf extends Entity implements HasCoverImage
+/**
+ * @property string $description
+ * @property string $description_html
+ */
+class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface
 {
     use HasFactory;
-    use HasHtmlDescription;
-
-    protected $table = 'bookshelves';
+    use ContainerTrait;
 
     public float $searchFactor = 1.2;
 
-    protected $fillable = ['name', 'description', 'image_id'];
-
-    protected $hidden = ['image_id', 'deleted_at', 'description_html'];
+    protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
+    protected $fillable = ['name'];
 
     /**
      * Get the books in this shelf.
-     * Should not be used directly since does not take into account permissions.
-     *
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+     * Should not be used directly since it does not take into account permissions.
      */
-    public function books()
+    public function books(): BelongsToMany
     {
         return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')
+            ->select(['entities.*', 'entity_container_data.*'])
             ->withPivot('order')
             ->orderBy('order', 'asc');
     }
@@ -50,40 +50,6 @@ class Bookshelf extends Entity implements HasCoverImage
         return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
     }
 
-    /**
-     * Returns shelf cover image, if cover not exists return default cover image.
-     */
-    public function getBookCover(int $width = 440, int $height = 250): string
-    {
-        // TODO - Make generic, focused on books right now, Perhaps set-up a better image
-        $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
-        if (!$this->image_id || !$this->cover) {
-            return $default;
-        }
-
-        try {
-            return $this->cover->getThumb($width, $height, false) ?? $default;
-        } catch (Exception $err) {
-            return $default;
-        }
-    }
-
-    /**
-     * Get the cover image of the shelf.
-     */
-    public function cover(): BelongsTo
-    {
-        return $this->belongsTo(Image::class, 'image_id');
-    }
-
-    /**
-     * Get the type of the image model that is used when storing a cover image.
-     */
-    public function coverImageTypeKey(): string
-    {
-        return 'cover_bookshelf';
-    }
-
     /**
      * Check if this shelf contains the given book.
      */
@@ -95,7 +61,7 @@ class Bookshelf extends Entity implements HasCoverImage
     /**
      * Add a book to the end of this shelf.
      */
-    public function appendBook(Book $book)
+    public function appendBook(Book $book): void
     {
         if ($this->contains($book)) {
             return;
@@ -105,12 +71,13 @@ class Bookshelf extends Entity implements HasCoverImage
         $this->books()->attach($book->id, ['order' => $maxOrder + 1]);
     }
 
-    /**
-     * Get a visible shelf by its slug.
-     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
-     */
-    public static function getBySlug(string $slug): self
+    public function coverInfo(): EntityCover
     {
-        return static::visible()->where('slug', '=', $slug)->firstOrFail();
+        return new EntityCover($this);
+    }
+
+    public function cover(): BelongsTo
+    {
+        return $this->belongsTo(Image::class, 'image_id');
     }
 }
diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php
index 088d199da..2dd4cb77f 100644
--- a/app/Entities/Models/Chapter.php
+++ b/app/Entities/Models/Chapter.php
@@ -2,32 +2,30 @@
 
 namespace BookStack\Entities\Models;
 
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use BookStack\Entities\Tools\EntityDefaultTemplate;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Support\Collection;
 
 /**
- * Class Chapter.
- *
  * @property Collection<Page> $pages
  * @property ?int             $default_template_id
- * @property ?Page            $defaultTemplate
+ * @property string           $description
+ * @property string           $description_html
  */
-class Chapter extends BookChild
+class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface
 {
     use HasFactory;
-    use HasHtmlDescription;
+    use ContainerTrait;
 
     public float $searchFactor = 1.2;
-
-    protected $fillable = ['name', 'description', 'priority'];
-    protected $hidden = ['pivot', 'deleted_at', 'description_html'];
+    protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id'];
+    protected $fillable = ['name', 'priority'];
 
     /**
      * Get the pages that this chapter contains.
      *
-     * @return HasMany<Page>
+     * @return HasMany<Page, $this>
      */
     public function pages(string $dir = 'ASC'): HasMany
     {
@@ -50,17 +48,9 @@ class Chapter extends BookChild
         return url('/' . implode('/', $parts));
     }
 
-    /**
-     * Get the Page that is used as default template for newly created pages within this Chapter.
-     */
-    public function defaultTemplate(): BelongsTo
-    {
-        return $this->belongsTo(Page::class, 'default_template_id');
-    }
-
     /**
      * Get the visible pages in this chapter.
-     * @returns Collection<Page>
+     * @return Collection<Page>
      */
     public function getVisiblePages(): Collection
     {
@@ -70,4 +60,9 @@ class Chapter extends BookChild
         ->orderBy('priority', 'asc')
         ->get();
     }
+
+    public function defaultTemplate(): EntityDefaultTemplate
+    {
+        return new EntityDefaultTemplate($this);
+    }
 }
diff --git a/app/Entities/Models/ContainerTrait.php b/app/Entities/Models/ContainerTrait.php
new file mode 100644
index 000000000..9ef5ca8d4
--- /dev/null
+++ b/app/Entities/Models/ContainerTrait.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityHtmlDescription;
+use Illuminate\Database\Eloquent\Relations\HasOne;
+
+/**
+ * @mixin Entity
+ */
+trait ContainerTrait
+{
+    public function descriptionInfo(): EntityHtmlDescription
+    {
+        return new EntityHtmlDescription($this);
+    }
+
+    /**
+     * @return HasOne<EntityContainerData, $this>
+     */
+    public function relatedData(): HasOne
+    {
+        return $this->hasOne(EntityContainerData::class, 'entity_id', 'id')
+            ->where('entity_type', '=', $this->getMorphClass());
+    }
+}
diff --git a/app/Entities/Models/Deletable.php b/app/Entities/Models/DeletableInterface.php
similarity index 90%
rename from app/Entities/Models/Deletable.php
rename to app/Entities/Models/DeletableInterface.php
index a2c7fad81..f771d9c69 100644
--- a/app/Entities/Models/Deletable.php
+++ b/app/Entities/Models/DeletableInterface.php
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
  * A model that can be deleted in a manner that deletions
  * are tracked to be part of the recycle bin system.
  */
-interface Deletable
+interface DeletableInterface
 {
     public function deletions(): MorphMany;
 }
diff --git a/app/Entities/Models/Deletion.php b/app/Entities/Models/Deletion.php
index a73437c94..c24c72d44 100644
--- a/app/Entities/Models/Deletion.php
+++ b/app/Entities/Models/Deletion.php
@@ -4,6 +4,7 @@ namespace BookStack\Entities\Models;
 
 use BookStack\Activity\Models\Loggable;
 use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -13,10 +14,12 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
  * @property int       $deleted_by
  * @property string    $deletable_type
  * @property int       $deletable_id
- * @property Deletable $deletable
+ * @property DeletableInterface $deletable
  */
 class Deletion extends Model implements Loggable
 {
+    use HasFactory;
+
     protected $hidden = [];
 
     /**
diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php
index 0de83c938..47e134626 100644
--- a/app/Entities/Models/Entity.php
+++ b/app/Entities/Models/Entity.php
@@ -12,8 +12,7 @@ use BookStack\Activity\Models\View;
 use BookStack\Activity\Models\Viewable;
 use BookStack\Activity\Models\Watch;
 use BookStack\App\Model;
-use BookStack\App\Sluggable;
-use BookStack\Entities\Tools\SlugGenerator;
+use BookStack\App\SluggableInterface;
 use BookStack\Permissions\JointPermissionBuilder;
 use BookStack\Permissions\Models\EntityPermission;
 use BookStack\Permissions\Models\JointPermission;
@@ -22,37 +21,47 @@ use BookStack\References\Reference;
 use BookStack\Search\SearchIndex;
 use BookStack\Search\SearchTerm;
 use BookStack\Users\Models\HasCreatorAndUpdater;
-use BookStack\Users\Models\HasOwner;
+use BookStack\Users\Models\OwnableInterface;
+use BookStack\Users\Models\User;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Database\Eloquent\Relations\MorphMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 /**
  * Class Entity
- * The base class for book-like items such as pages, chapters & books.
+ * The base class for book-like items such as pages, chapters and books.
  * This is not a database model in itself but extended.
  *
  * @property int        $id
+ * @property string     $type
  * @property string     $name
  * @property string     $slug
  * @property Carbon     $created_at
  * @property Carbon     $updated_at
  * @property Carbon     $deleted_at
- * @property int        $created_by
- * @property int        $updated_by
+ * @property int|null   $created_by
+ * @property int|null   $updated_by
+ * @property int|null   $owned_by
  * @property Collection $tags
  *
  * @method static Entity|Builder visible()
  * @method static Builder withLastView()
  * @method static Builder withViewCount()
  */
-abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
+abstract class Entity extends Model implements
+    SluggableInterface,
+    Favouritable,
+    Viewable,
+    DeletableInterface,
+    OwnableInterface,
+    Loggable
 {
     use SoftDeletes;
     use HasCreatorAndUpdater;
-    use HasOwner;
 
     /**
      * @var string - Name of property where the main text content is found
@@ -69,6 +78,72 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
      */
     public float $searchFactor = 1.0;
 
+    /**
+     * Set the table to be that used by all entities.
+     */
+    protected $table = 'entities';
+
+    /**
+     * Set a custom query builder for entities.
+     */
+    protected static string $builder = EntityQueryBuilder::class;
+
+    public static array $commonFields = [
+        'id',
+        'type',
+        'name',
+        'slug',
+        'book_id',
+        'chapter_id',
+        'priority',
+        'created_at',
+        'updated_at',
+        'deleted_at',
+        'created_by',
+        'updated_by',
+        'owned_by',
+    ];
+
+    /**
+     * Override the save method to also save the contents for convenience.
+     */
+    public function save(array $options = []): bool
+    {
+        /** @var EntityPageData|EntityContainerData $contents */
+        $contents = $this->relatedData()->firstOrNew();
+        $contentFields = $this->getContentsAttributes();
+
+        foreach ($contentFields as $key => $value) {
+            $contents->setAttribute($key, $value);
+            unset($this->attributes[$key]);
+        }
+
+        $this->setAttribute('type', $this->getMorphClass());
+        $result = parent::save($options);
+        $contentsResult = true;
+
+        if ($result && $contents->isDirty()) {
+            $contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()];
+            $contents->forceFill($contentsFillData);
+            $contentsResult = $contents->save();
+            $this->touch();
+        }
+
+        $this->forceFill($contentFields);
+
+        return $result && $contentsResult;
+    }
+
+    /**
+     * Check if this item is a container item.
+     */
+    public function isContainer(): bool
+    {
+        return $this instanceof Bookshelf ||
+            $this instanceof Book ||
+            $this instanceof Chapter;
+    }
+
     /**
      * Get the entities that are visible to the current user.
      */
@@ -83,8 +158,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     public function scopeWithLastView(Builder $query)
     {
         $viewedAtQuery = View::query()->select('updated_at')
-            ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
-            ->where('viewable_type', '=', $this->getMorphClass())
+            ->whereColumn('viewable_id', '=', 'entities.id')
+            ->whereColumn('viewable_type', '=', 'entities.type')
             ->where('user_id', '=', user()->id)
             ->take(1);
 
@@ -94,11 +169,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     /**
      * Query scope to get the total view count of the entities.
      */
-    public function scopeWithViewCount(Builder $query)
+    public function scopeWithViewCount(Builder $query): void
     {
         $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
-            ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
-            ->where('viewable_type', '=', $this->getMorphClass())->take(1);
+            ->whereColumn('viewable_id', '=', 'entities.id')
+            ->whereColumn('viewable_type', '=', 'entities.type')
+            ->take(1);
 
         $query->addSelect(['view_count' => $viewCountQuery]);
     }
@@ -154,15 +230,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
      */
     public function tags(): MorphMany
     {
-        return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
+        return $this->morphMany(Tag::class, 'entity')
+            ->orderBy('order', 'asc');
     }
 
     /**
      * Get the comments for an entity.
+     * @return MorphMany<Comment, $this>
      */
     public function comments(bool $orderByCreated = true): MorphMany
     {
-        $query = $this->morphMany(Comment::class, 'entity');
+        $query = $this->morphMany(Comment::class, 'commentable');
 
         return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
     }
@@ -176,7 +254,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     }
 
     /**
-     * Get this entities restrictions.
+     * Get this entities assigned permissions.
      */
     public function permissions(): MorphMany
     {
@@ -199,6 +277,20 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
         return $this->morphMany(JointPermission::class, 'entity');
     }
 
+    /**
+     * Get the user who owns this entity.
+     * @return BelongsTo<User, $this>
+     */
+    public function ownedBy(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'owned_by');
+    }
+
+    public function getOwnerFieldName(): string
+    {
+        return 'owned_by';
+    }
+
     /**
      * Get the related delete records for this entity.
      */
@@ -245,7 +337,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     }
 
     /**
-     * Gets a limited-length version of the entities name.
+     * Gets a limited-length version of the entity name.
      */
     public function getShortName(int $length = 25): string
     {
@@ -283,10 +375,14 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     public function getParent(): ?self
     {
         if ($this instanceof Page) {
-            return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
+            /** @var BelongsTo<Chapter|Book, Page>  $builder */
+            $builder = $this->chapter_id ? $this->chapter() : $this->book();
+            return $builder->withTrashed()->first();
         }
         if ($this instanceof Chapter) {
-            return $this->book()->withTrashed()->first();
+            /** @var BelongsTo<Book, Page>  $builder */
+            $builder = $this->book();
+            return $builder->withTrashed()->first();
         }
 
         return null;
@@ -295,7 +391,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     /**
      * Rebuild the permissions for this entity.
      */
-    public function rebuildPermissions()
+    public function rebuildPermissions(): void
     {
         app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
     }
@@ -303,21 +399,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     /**
      * Index the current entity for search.
      */
-    public function indexForSearch()
+    public function indexForSearch(): void
     {
         app()->make(SearchIndex::class)->indexEntity(clone $this);
     }
 
-    /**
-     * {@inheritdoc}
-     */
-    public function refreshSlug(): string
-    {
-        $this->slug = app()->make(SlugGenerator::class)->generate($this);
-
-        return $this->slug;
-    }
-
     /**
      * {@inheritdoc}
      */
@@ -344,6 +430,14 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
         return $this->morphMany(Watch::class, 'watchable');
     }
 
+    /**
+     * Get the related slug history for this entity.
+     */
+    public function slugHistory(): MorphMany
+    {
+        return $this->morphMany(SlugHistory::class, 'sluggable');
+    }
+
     /**
      * {@inheritdoc}
      */
@@ -351,4 +445,40 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
     {
         return "({$this->id}) {$this->name}";
     }
+
+    /**
+     * @return HasOne<covariant (EntityContainerData|EntityPageData), $this>
+     */
+    abstract public function relatedData(): HasOne;
+
+    /**
+     * Get the attributes that are intended for the related contents model.
+     * @return array<string, mixed>
+     */
+    protected function getContentsAttributes(): array
+    {
+        $contentFields = [];
+        $contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class;
+
+        foreach ($this->attributes as $key => $value) {
+            if (in_array($key, $contentModel::$fields)) {
+                $contentFields[$key] = $value;
+            }
+        }
+
+        return $contentFields;
+    }
+
+    /**
+     * Create a new instance for the given entity type.
+     */
+    public static function instanceFromType(string $type): self
+    {
+        return match ($type) {
+            'page' => new Page(),
+            'chapter' => new Chapter(),
+            'book' => new Book(),
+            'bookshelf' => new Bookshelf(),
+        };
+    }
 }
diff --git a/app/Entities/Models/EntityContainerData.php b/app/Entities/Models/EntityContainerData.php
new file mode 100644
index 000000000..21bace751
--- /dev/null
+++ b/app/Entities/Models/EntityContainerData.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * @property int     $entity_id
+ * @property string  $entity_type
+ * @property string  $description
+ * @property string  $description_html
+ * @property ?int    $default_template_id
+ * @property ?int    $image_id
+ * @property ?int    $sort_rule_id
+ */
+class EntityContainerData extends Model
+{
+    public $timestamps = false;
+    protected $primaryKey = 'entity_id';
+    public $incrementing = false;
+
+    public static array $fields = [
+        'description',
+        'description_html',
+        'default_template_id',
+        'image_id',
+        'sort_rule_id',
+    ];
+
+    /**
+     * Override the default set keys for save query method to make it work with composite keys.
+     */
+    public function setKeysForSaveQuery($query): Builder
+    {
+        $query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery())
+            ->where('entity_type', '=', $this->entity_type);
+
+        return $query;
+    }
+
+    /**
+     * Override the default set keys for a select query method to make it work with composite keys.
+     */
+    protected function setKeysForSelectQuery($query): Builder
+    {
+        $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery())
+            ->where('entity_type', '=', $this->entity_type);
+
+        return $query;
+    }
+}
diff --git a/app/Entities/Models/EntityPageData.php b/app/Entities/Models/EntityPageData.php
new file mode 100644
index 000000000..a98b1a982
--- /dev/null
+++ b/app/Entities/Models/EntityPageData.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * @property int    $page_id
+ */
+class EntityPageData extends Model
+{
+    public $timestamps = false;
+    protected $primaryKey = 'page_id';
+    public $incrementing = false;
+
+    public static array $fields = [
+        'draft',
+        'template',
+        'revision_count',
+        'editor',
+        'html',
+        'text',
+        'markdown',
+    ];
+}
diff --git a/app/Entities/Models/EntityQueryBuilder.php b/app/Entities/Models/EntityQueryBuilder.php
new file mode 100644
index 000000000..7db16b2c9
--- /dev/null
+++ b/app/Entities/Models/EntityQueryBuilder.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Query\Builder as QueryBuilder;
+
+class EntityQueryBuilder extends Builder
+{
+    /**
+     * Create a new Eloquent query builder instance.
+     */
+    public function __construct(QueryBuilder $query)
+    {
+        parent::__construct($query);
+
+        $this->withGlobalScope('entity', new EntityScope());
+    }
+
+    public function withoutGlobalScope($scope): static
+    {
+        // Prevent removal of the entity scope
+        if ($scope === 'entity') {
+            return $this;
+        }
+
+        return parent::withoutGlobalScope($scope);
+    }
+
+    /**
+     * Override the default forceDelete method to add type filter onto the query
+     * since it specifically ignores scopes by default.
+     */
+    public function forceDelete()
+    {
+        return $this->query->where('type', '=', $this->model->getMorphClass())->delete();
+    }
+}
diff --git a/app/Entities/Models/EntityScope.php b/app/Entities/Models/EntityScope.php
new file mode 100644
index 000000000..c77b75a16
--- /dev/null
+++ b/app/Entities/Models/EntityScope.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Scope;
+use Illuminate\Database\Query\JoinClause;
+
+class EntityScope implements Scope
+{
+    /**
+     * Apply the scope to a given Eloquent query builder.
+     */
+    public function apply(Builder $builder, Model $model): void
+    {
+        $builder = $builder->where('type', '=', $model->getMorphClass());
+        $table = $model->getTable();
+        if ($model instanceof Page) {
+            $builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', "{$table}.id");
+        } else {
+            $builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model, $table) {
+                $join->on('entity_container_data.entity_id', '=', "{$table}.id")
+                    ->where('entity_container_data.entity_type', '=', $model->getMorphClass());
+            });
+        }
+    }
+}
diff --git a/app/Entities/Models/EntityTable.php b/app/Entities/Models/EntityTable.php
new file mode 100644
index 000000000..5780162d1
--- /dev/null
+++ b/app/Entities/Models/EntityTable.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Activity\Models\Tag;
+use BookStack\Activity\Models\View;
+use BookStack\App\Model;
+use BookStack\Permissions\Models\EntityPermission;
+use BookStack\Permissions\Models\JointPermission;
+use BookStack\Permissions\PermissionApplicator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\MorphMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * This is a simplistic model interpretation of a generic Entity used to query and represent
+ * that database abstractly. Generally, this should rarely be used outside queries.
+ */
+class EntityTable extends Model
+{
+    use SoftDeletes;
+
+    protected $table = 'entities';
+
+    /**
+     * Get the entities that are visible to the current user.
+     */
+    public function scopeVisible(Builder $query): Builder
+    {
+        return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
+    }
+
+    /**
+     * Get the entity jointPermissions this is connected to.
+     */
+    public function jointPermissions(): HasMany
+    {
+        return $this->hasMany(JointPermission::class, 'entity_id')
+            ->whereColumn('entity_type', '=', 'entities.type');
+    }
+
+    /**
+     * Get the Tags that have been assigned to entities.
+     */
+    public function tags(): HasMany
+    {
+        return $this->hasMany(Tag::class, 'entity_id')
+            ->whereColumn('entity_type', '=', 'entities.type');
+    }
+
+    /**
+     * Get the assigned permissions.
+     */
+    public function permissions(): HasMany
+    {
+        return $this->hasMany(EntityPermission::class, 'entity_id')
+            ->whereColumn('entity_type', '=', 'entities.type');
+    }
+
+    /**
+     * Get View objects for this entity.
+     */
+    public function views(): HasMany
+    {
+        return $this->hasMany(View::class, 'viewable_id')
+            ->whereColumn('viewable_type', '=', 'entities.type');
+    }
+}
diff --git a/app/Entities/Models/HasCoverImage.php b/app/Entities/Models/HasCoverImage.php
deleted file mode 100644
index f665efce6..000000000
--- a/app/Entities/Models/HasCoverImage.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-
-namespace BookStack\Entities\Models;
-
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
-
-interface HasCoverImage
-{
-    /**
-     * Get the cover image for this item.
-     */
-    public function cover(): BelongsTo;
-
-    /**
-     * Get the type of the image model that is used when storing a cover image.
-     */
-    public function coverImageTypeKey(): string;
-}
diff --git a/app/Entities/Models/HasCoverInterface.php b/app/Entities/Models/HasCoverInterface.php
new file mode 100644
index 000000000..a4e79e900
--- /dev/null
+++ b/app/Entities/Models/HasCoverInterface.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityCover;
+use BookStack\Uploads\Image;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+interface HasCoverInterface
+{
+    public function coverInfo(): EntityCover;
+
+    /**
+     * The cover image of this entity.
+     * @return BelongsTo<Image, covariant Entity>
+     */
+    public function cover(): BelongsTo;
+}
diff --git a/app/Entities/Models/HasDefaultTemplateInterface.php b/app/Entities/Models/HasDefaultTemplateInterface.php
new file mode 100644
index 000000000..f3af0da48
--- /dev/null
+++ b/app/Entities/Models/HasDefaultTemplateInterface.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityDefaultTemplate;
+
+interface HasDefaultTemplateInterface
+{
+    public function defaultTemplate(): EntityDefaultTemplate;
+}
diff --git a/app/Entities/Models/HasDescriptionInterface.php b/app/Entities/Models/HasDescriptionInterface.php
new file mode 100644
index 000000000..f35dbdcb2
--- /dev/null
+++ b/app/Entities/Models/HasDescriptionInterface.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\EntityHtmlDescription;
+
+interface HasDescriptionInterface
+{
+    public function descriptionInfo(): EntityHtmlDescription;
+}
diff --git a/app/Entities/Models/HasHtmlDescription.php b/app/Entities/Models/HasHtmlDescription.php
deleted file mode 100644
index c9f08616d..000000000
--- a/app/Entities/Models/HasHtmlDescription.php
+++ /dev/null
@@ -1,21 +0,0 @@
-<?php
-
-namespace BookStack\Entities\Models;
-
-use BookStack\Util\HtmlContentFilter;
-
-/**
- * @property string $description
- * @property string $description_html
- */
-trait HasHtmlDescription
-{
-    /**
-     * Get the HTML description for this book.
-     */
-    public function descriptionHtml(): string
-    {
-        $html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
-        return HtmlContentFilter::removeScriptsFromHtmlString($html);
-    }
-}
diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php
index 499ef4d72..a1d3fc7b4 100644
--- a/app/Entities/Models/Page.php
+++ b/app/Entities/Models/Page.php
@@ -3,7 +3,6 @@
 namespace BookStack\Entities\Models;
 
 use BookStack\Entities\Tools\PageContent;
-use BookStack\Entities\Tools\PageEditorType;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Uploads\Attachment;
 use Illuminate\Database\Eloquent\Builder;
@@ -15,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
 
 /**
  * Class Page.
- *
+ * @property EntityPageData $pageData
  * @property int          $chapter_id
  * @property string       $html
  * @property string       $markdown
@@ -33,12 +32,10 @@ class Page extends BookChild
 {
     use HasFactory;
 
-    protected $fillable = ['name', 'priority'];
-
     public string $textField = 'text';
     public string $htmlField = 'html';
-
-    protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
+    protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at',  'entity_id', 'entity_type'];
+    protected $fillable = ['name', 'priority'];
 
     protected $casts = [
         'draft'    => 'boolean',
@@ -57,10 +54,8 @@ class Page extends BookChild
 
     /**
      * Get the chapter that this page is in, If applicable.
-     *
-     * @return BelongsTo
      */
-    public function chapter()
+    public function chapter(): BelongsTo
     {
         return $this->belongsTo(Chapter::class);
     }
@@ -107,10 +102,8 @@ class Page extends BookChild
 
     /**
      * Get the attachments assigned to this page.
-     *
-     * @return HasMany
      */
-    public function attachments()
+    public function attachments(): HasMany
     {
         return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
     }
@@ -131,6 +124,14 @@ class Page extends BookChild
         return url('/' . implode('/', $parts));
     }
 
+    /**
+     * Get the ID-based permalink for this page.
+     */
+    public function getPermalink(): string
+    {
+        return url("/link/{$this->id}");
+    }
+
     /**
      * Get this page for JSON display.
      */
@@ -139,8 +140,16 @@ class Page extends BookChild
         $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
         $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
         $refreshed->setAttribute('raw_html', $refreshed->html);
-        $refreshed->html = (new PageContent($refreshed))->render();
+        $refreshed->setAttribute('html', (new PageContent($refreshed))->render());
 
         return $refreshed;
     }
+
+    /**
+     * @return HasOne<EntityPageData, $this>
+     */
+    public function relatedData(): HasOne
+    {
+        return $this->hasOne(EntityPageData::class, 'page_id', 'id');
+    }
 }
diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php
index 10ff6d901..4409afdc2 100644
--- a/app/Entities/Models/PageRevision.php
+++ b/app/Entities/Models/PageRevision.php
@@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable;
 use BookStack\App\Model;
 use BookStack\Users\Models\User;
 use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
@@ -30,6 +31,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
  */
 class PageRevision extends Model implements Loggable
 {
+    use HasFactory;
+
     protected $fillable = ['name', 'text', 'summary'];
     protected $hidden = ['html', 'markdown', 'text'];
 
@@ -60,7 +63,7 @@ class PageRevision extends Model implements Loggable
     /**
      * Get the previous revision for the same page if existing.
      */
-    public function getPrevious(): ?PageRevision
+    public function getPreviousRevision(): ?PageRevision
     {
         $id = static::newQuery()->where('page_id', '=', $this->page_id)
             ->where('id', '<', $this->id)
diff --git a/app/Entities/Models/SlugHistory.php b/app/Entities/Models/SlugHistory.php
new file mode 100644
index 000000000..4041cedd9
--- /dev/null
+++ b/app/Entities/Models/SlugHistory.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\App\Model;
+use BookStack\Permissions\Models\JointPermission;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+/**
+ * @property int $id
+ * @property int $sluggable_id
+ * @property string $sluggable_type
+ * @property string $slug
+ * @property ?string $parent_slug
+ */
+class SlugHistory extends Model
+{
+    use HasFactory;
+
+    protected $table = 'slug_history';
+
+    public function jointPermissions(): HasMany
+    {
+        return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
+            ->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
+    }
+}
diff --git a/app/Entities/Queries/BookQueries.php b/app/Entities/Queries/BookQueries.php
index 534640621..a466f37bc 100644
--- a/app/Entities/Queries/BookQueries.php
+++ b/app/Entities/Queries/BookQueries.php
@@ -6,6 +6,9 @@ use BookStack\Entities\Models\Book;
 use BookStack\Exceptions\NotFoundException;
 use Illuminate\Database\Eloquent\Builder;
 
+/**
+ * @implements ProvidesEntityQueries<Book>
+ */
 class BookQueries implements ProvidesEntityQueries
 {
     protected static array $listAttributes = [
@@ -13,6 +16,9 @@ class BookQueries implements ProvidesEntityQueries
         'created_at', 'updated_at', 'image_id', 'owned_by',
     ];
 
+    /**
+     * @return Builder<Book>
+     */
     public function start(): Builder
     {
         return Book::query();
@@ -49,6 +55,11 @@ class BookQueries implements ProvidesEntityQueries
             ->select(static::$listAttributes);
     }
 
+    public function visibleForContent(): Builder
+    {
+        return $this->start()->scopes('visible');
+    }
+
     public function visibleForListWithCover(): Builder
     {
         return $this->visibleForList()->with('cover');
diff --git a/app/Entities/Queries/BookshelfQueries.php b/app/Entities/Queries/BookshelfQueries.php
index 19717fb7c..3fe0a2afc 100644
--- a/app/Entities/Queries/BookshelfQueries.php
+++ b/app/Entities/Queries/BookshelfQueries.php
@@ -6,6 +6,9 @@ use BookStack\Entities\Models\Bookshelf;
 use BookStack\Exceptions\NotFoundException;
 use Illuminate\Database\Eloquent\Builder;
 
+/**
+ * @implements ProvidesEntityQueries<Bookshelf>
+ */
 class BookshelfQueries implements ProvidesEntityQueries
 {
     protected static array $listAttributes = [
@@ -13,6 +16,9 @@ class BookshelfQueries implements ProvidesEntityQueries
         'created_at', 'updated_at', 'image_id', 'owned_by',
     ];
 
+    /**
+     * @return Builder<Bookshelf>
+     */
     public function start(): Builder
     {
         return Bookshelf::query();
@@ -54,6 +60,11 @@ class BookshelfQueries implements ProvidesEntityQueries
         return $this->start()->scopes('visible')->select(static::$listAttributes);
     }
 
+    public function visibleForContent(): Builder
+    {
+        return $this->start()->scopes('visible');
+    }
+
     public function visibleForListWithCover(): Builder
     {
         return $this->visibleForList()->with('cover');
diff --git a/app/Entities/Queries/ChapterQueries.php b/app/Entities/Queries/ChapterQueries.php
index 53c5bc9d8..9ddeb9b58 100644
--- a/app/Entities/Queries/ChapterQueries.php
+++ b/app/Entities/Queries/ChapterQueries.php
@@ -6,6 +6,9 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Exceptions\NotFoundException;
 use Illuminate\Database\Eloquent\Builder;
 
+/**
+ * @implements ProvidesEntityQueries<Chapter>
+ */
 class ChapterQueries implements ProvidesEntityQueries
 {
     protected static array $listAttributes = [
@@ -62,8 +65,14 @@ class ChapterQueries implements ProvidesEntityQueries
             ->scopes('visible')
             ->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
                 $builder->select('slug')
-                    ->from('books')
-                    ->whereColumn('books.id', '=', 'chapters.book_id');
+                    ->from('entities as books')
+                    ->where('type', '=', 'book')
+                    ->whereColumn('books.id', '=', 'entities.book_id');
             }]));
     }
+
+    public function visibleForContent(): Builder
+    {
+        return $this->start()->scopes('visible');
+    }
 }
diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php
index 36dc6c0bc..3ffa0adf3 100644
--- a/app/Entities/Queries/EntityQueries.php
+++ b/app/Entities/Queries/EntityQueries.php
@@ -3,7 +3,12 @@
 namespace BookStack\Entities\Queries;
 
 use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\EntityTable;
+use BookStack\Entities\Tools\SlugHistory;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Query\Builder as QueryBuilder;
+use Illuminate\Database\Query\JoinClause;
+use Illuminate\Support\Facades\DB;
 use InvalidArgumentException;
 
 class EntityQueries
@@ -14,6 +19,7 @@ class EntityQueries
         public ChapterQueries $chapters,
         public PageQueries $pages,
         public PageRevisionQueries $revisions,
+        protected SlugHistory $slugHistory,
     ) {
     }
 
@@ -27,24 +33,81 @@ class EntityQueries
         $explodedId = explode(':', $identifier);
         $entityType = $explodedId[0];
         $entityId = intval($explodedId[1]);
-        $queries = $this->getQueriesForType($entityType);
 
-        return $queries->findVisibleById($entityId);
+        return $this->findVisibleById($entityType, $entityId);
+    }
+
+    /**
+     * Find an entity by its ID.
+     */
+    public function findVisibleById(string $type, int $id): ?Entity
+    {
+        $queries = $this->getQueriesForType($type);
+        return $queries->findVisibleById($id);
+    }
+
+    /**
+     * Find an entity by looking up old slugs in the slug history.
+     */
+    public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
+    {
+        $id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
+        if ($id === null) {
+            return null;
+        }
+
+        return $this->findVisibleById($type, $id);
+    }
+
+    /**
+     * Start a query across all entity types.
+     * Combines the description/text fields into a single 'description' field.
+     * @return Builder<EntityTable>
+     */
+    public function visibleForList(): Builder
+    {
+        $rawDescriptionField = DB::raw('COALESCE(description, text) as description');
+        $bookSlugSelect = function (QueryBuilder $query) {
+            return $query->select('slug')->from('entities as books')
+                ->whereColumn('books.id', '=', 'entities.book_id')
+                ->where('type', '=', 'book');
+        };
+
+        return EntityTable::query()->scopes('visible')
+            ->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField])
+            ->leftJoin('entity_container_data', function (JoinClause $join) {
+                $join->on('entity_container_data.entity_id', '=', 'entities.id')
+                    ->on('entity_container_data.entity_type', '=', 'entities.type');
+            })->leftJoin('entity_page_data', function (JoinClause $join) {
+                $join->on('entity_page_data.page_id', '=', 'entities.id')
+                    ->where('entities.type', '=', 'page');
+            });
     }
 
     /**
      * Start a query of visible entities of the given type,
      * suitable for listing display.
+     * @return Builder<Entity>
      */
-    public function visibleForList(string $entityType): Builder
+    public function visibleForListForType(string $entityType): Builder
     {
         $queries = $this->getQueriesForType($entityType);
         return $queries->visibleForList();
     }
 
+    /**
+     * Start a query of visible entities of the given type,
+     * suitable for using the contents of the items.
+     * @return Builder<Entity>
+     */
+    public function visibleForContentForType(string $entityType): Builder
+    {
+        $queries = $this->getQueriesForType($entityType);
+        return $queries->visibleForContent();
+    }
+
     protected function getQueriesForType(string $type): ProvidesEntityQueries
     {
-        /** @var ?ProvidesEntityQueries $queries */
         $queries = match ($type) {
             'page' => $this->pages,
             'chapter' => $this->chapters,
diff --git a/app/Entities/Queries/PageQueries.php b/app/Entities/Queries/PageQueries.php
index 06298f470..f4ecee2dc 100644
--- a/app/Entities/Queries/PageQueries.php
+++ b/app/Entities/Queries/PageQueries.php
@@ -6,11 +6,14 @@ use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\NotFoundException;
 use Illuminate\Database\Eloquent\Builder;
 
+/**
+ * @implements ProvidesEntityQueries<Page>
+ */
 class PageQueries implements ProvidesEntityQueries
 {
     protected static array $contentAttributes = [
         'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
-        'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
+        'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority',
         'created_by', 'updated_by', 'owned_by',
     ];
     protected static array $listAttributes = [
@@ -18,6 +21,9 @@ class PageQueries implements ProvidesEntityQueries
         'template', 'text', 'created_at', 'updated_at', 'priority', 'owned_by',
     ];
 
+    /**
+     * @return Builder<Page>
+     */
     public function start(): Builder
     {
         return Page::query();
@@ -66,6 +72,9 @@ class PageQueries implements ProvidesEntityQueries
             });
     }
 
+    /**
+     * @return Builder<Page>
+     */
     public function visibleForList(): Builder
     {
         return $this->start()
@@ -73,6 +82,14 @@ class PageQueries implements ProvidesEntityQueries
             ->select($this->mergeBookSlugForSelect(static::$listAttributes));
     }
 
+    /**
+     * @return Builder<Page>
+     */
+    public function visibleForContent(): Builder
+    {
+        return $this->start()->scopes('visible');
+    }
+
     public function visibleForChapterList(int $chapterId): Builder
     {
         return $this->visibleForList()
@@ -95,18 +112,19 @@ class PageQueries implements ProvidesEntityQueries
             ->where('created_by', '=', user()->id);
     }
 
-    public function visibleTemplates(): Builder
+    public function visibleTemplates(bool $includeContents = false): Builder
     {
-        return $this->visibleForList()
-            ->where('template', '=', true);
+        $base = $includeContents ? $this->visibleWithContents() : $this->visibleForList();
+        return $base->where('template', '=', true);
     }
 
     protected function mergeBookSlugForSelect(array $columns): array
     {
         return array_merge($columns, ['book_slug' => function ($builder) {
             $builder->select('slug')
-                ->from('books')
-                ->whereColumn('books.id', '=', 'pages.book_id');
+                ->from('entities as books')
+                ->where('type', '=', 'book')
+                ->whereColumn('books.id', '=', 'entities.book_id');
         }]);
     }
 }
diff --git a/app/Entities/Queries/ProvidesEntityQueries.php b/app/Entities/Queries/ProvidesEntityQueries.php
index 611d0ae52..674e96afa 100644
--- a/app/Entities/Queries/ProvidesEntityQueries.php
+++ b/app/Entities/Queries/ProvidesEntityQueries.php
@@ -7,28 +7,39 @@ use Illuminate\Database\Eloquent\Builder;
 
 /**
  * Interface for our classes which provide common queries for our
- * entity objects. Ideally all queries for entities should run through
+ * entity objects. Ideally, all queries for entities should run through
  * these classes.
  * Any added methods should return a builder instances to allow extension
  * via building on the query, unless the method starts with 'find'
  * in which case an entity object should be returned.
  * (nullable unless it's a *OrFail method).
+ *
+ * @template TModel of Entity
  */
 interface ProvidesEntityQueries
 {
     /**
      * Start a new query for this entity type.
+     * @return Builder<TModel>
      */
     public function start(): Builder;
 
     /**
-     * Find the entity of the given ID, or return null if not found.
+     * Find the entity of the given ID or return null if not found.
      */
     public function findVisibleById(int $id): ?Entity;
 
     /**
      * Start a query for items that are visible, with selection
      * configured for list display of this item.
+     * @return Builder<TModel>
      */
     public function visibleForList(): Builder;
+
+    /**
+     * Start a query for items that are visible, with selection
+     * configured for using the content of the items found.
+     * @return Builder<TModel>
+     */
+    public function visibleForContent(): Builder;
 }
diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php
index 151d5b055..717e9c9f8 100644
--- a/app/Entities/Repos/BaseRepo.php
+++ b/app/Entities/Repos/BaseRepo.php
@@ -3,13 +3,13 @@
 namespace BookStack\Entities\Repos;
 
 use BookStack\Activity\TagRepo;
-use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\BookChild;
-use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\HasCoverInterface;
+use BookStack\Entities\Models\HasDescriptionInterface;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\HasCoverImage;
-use BookStack\Entities\Models\HasHtmlDescription;
 use BookStack\Entities\Queries\PageQueries;
+use BookStack\Entities\Tools\SlugGenerator;
+use BookStack\Entities\Tools\SlugHistory;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\References\ReferenceStore;
 use BookStack\References\ReferenceUpdater;
@@ -27,22 +27,32 @@ class BaseRepo
         protected ReferenceStore $referenceStore,
         protected PageQueries $pageQueries,
         protected BookSorter $bookSorter,
+        protected SlugGenerator $slugGenerator,
+        protected SlugHistory $slugHistory,
     ) {
     }
 
     /**
      * Create a new entity in the system.
+     * @template T of Entity
+     * @param T $entity
+     * @return T
      */
-    public function create(Entity $entity, array $input)
+    public function create(Entity $entity, array $input): Entity
     {
+        $entity = (clone $entity)->refresh();
         $entity->fill($input);
-        $this->updateDescription($entity, $input);
         $entity->forceFill([
             'created_by' => user()->id,
             'updated_by' => user()->id,
             'owned_by'   => user()->id,
         ]);
-        $entity->refreshSlug();
+        $this->refreshSlug($entity);
+
+        if ($entity instanceof HasDescriptionInterface) {
+            $this->updateDescription($entity, $input);
+        }
+
         $entity->save();
 
         if (isset($input['tags'])) {
@@ -52,22 +62,31 @@ class BaseRepo
         $entity->refresh();
         $entity->rebuildPermissions();
         $entity->indexForSearch();
+
         $this->referenceStore->updateForEntity($entity);
+
+        return $entity;
     }
 
     /**
      * Update the given entity.
+     * @template T of Entity
+     * @param T $entity
+     * @return T
      */
-    public function update(Entity $entity, array $input)
+    public function update(Entity $entity, array $input): Entity
     {
         $oldUrl = $entity->getUrl();
 
         $entity->fill($input);
-        $this->updateDescription($entity, $input);
         $entity->updated_by = user()->id;
 
         if ($entity->isDirty('name') || empty($entity->slug)) {
-            $entity->refreshSlug();
+            $this->refreshSlug($entity);
+        }
+
+        if ($entity instanceof HasDescriptionInterface) {
+            $this->updateDescription($entity, $input);
         }
 
         $entity->save();
@@ -77,69 +96,42 @@ class BaseRepo
             $entity->touch();
         }
 
-        $entity->rebuildPermissions();
         $entity->indexForSearch();
         $this->referenceStore->updateForEntity($entity);
 
         if ($oldUrl !== $entity->getUrl()) {
             $this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
         }
+
+        return $entity;
     }
 
     /**
-     * Update the given items' cover image, or clear it.
-     *
-     * @param Entity&HasCoverImage $entity
+     * Update the given items' cover image or clear it.
      *
      * @throws ImageUploadException
      * @throws \Exception
      */
-    public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
+    public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void
     {
         if ($coverImage) {
-            $imageType = $entity->coverImageTypeKey();
-            $this->imageRepo->destroyImage($entity->cover()->first());
+            $imageType = 'cover_' . $entity->type;
+            $this->imageRepo->destroyImage($entity->coverInfo()->getImage());
             $image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
-            $entity->cover()->associate($image);
+            $entity->coverInfo()->setImage($image);
             $entity->save();
         }
 
         if ($removeImage) {
-            $this->imageRepo->destroyImage($entity->cover()->first());
-            $entity->image_id = 0;
+            $this->imageRepo->destroyImage($entity->coverInfo()->getImage());
+            $entity->coverInfo()->setImage(null);
             $entity->save();
         }
     }
 
     /**
-     * Update the default page template used for this item.
-     * Checks that, if changing, the provided value is a valid template and the user
-     * has visibility of the provided page template id.
-     */
-    public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
-    {
-        $changing = $templateId !== intval($entity->default_template_id);
-        if (!$changing) {
-            return;
-        }
-
-        if ($templateId === 0) {
-            $entity->default_template_id = null;
-            $entity->save();
-            return;
-        }
-
-        $templateExists = $this->pageQueries->visibleTemplates()
-            ->where('id', '=', $templateId)
-            ->exists();
-
-        $entity->default_template_id = $templateExists ? $templateId : null;
-        $entity->save();
-    }
-
-    /**
-     * Sort the parent of the given entity, if any auto sort actions are set for it.
-     * Typical ran during create/update/insert events.
+     * Sort the parent of the given entity if any auto sort actions are set for it.
+     * Typically ran during create/update/insert events.
      */
     public function sortParent(Entity $entity): void
     {
@@ -149,20 +141,31 @@ class BaseRepo
         }
     }
 
+    /**
+     * Update the description of the given entity from input data.
+     */
     protected function updateDescription(Entity $entity, array $input): void
     {
-        if (!in_array(HasHtmlDescription::class, class_uses($entity))) {
+        if (!$entity instanceof HasDescriptionInterface) {
             return;
         }
 
-        /** @var HasHtmlDescription $entity */
         if (isset($input['description_html'])) {
-            $entity->description_html = HtmlDescriptionFilter::filterFromString($input['description_html']);
-            $entity->description = html_entity_decode(strip_tags($input['description_html']));
+            $entity->descriptionInfo()->set(
+                HtmlDescriptionFilter::filterFromString($input['description_html']),
+                html_entity_decode(strip_tags($input['description_html']))
+            );
         } else if (isset($input['description'])) {
-            $entity->description = $input['description'];
-            $entity->description_html = '';
-            $entity->description_html = $entity->descriptionHtml();
+            $entity->descriptionInfo()->set('', $input['description']);
         }
     }
+
+    /**
+     * Refresh the slug for the given entity.
+     */
+    public function refreshSlug(Entity $entity): void
+    {
+        $this->slugHistory->recordForEntity($entity);
+        $this->slugGenerator->regenerateForEntity($entity);
+    }
 }
diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php
index 92e6a81c3..b4244b9bb 100644
--- a/app/Entities/Repos/BookRepo.php
+++ b/app/Entities/Repos/BookRepo.php
@@ -10,6 +10,7 @@ use BookStack\Exceptions\ImageUploadException;
 use BookStack\Facades\Activity;
 use BookStack\Sorting\SortRule;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 use Illuminate\Http\UploadedFile;
 
@@ -28,19 +29,21 @@ class BookRepo
      */
     public function create(array $input): Book
     {
-        $book = new Book();
-        $this->baseRepo->create($book, $input);
-        $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
-        $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
-        Activity::add(ActivityType::BOOK_CREATE, $book);
+        return (new DatabaseTransaction(function () use ($input) {
+            $book = $this->baseRepo->create(new Book(), $input);
+            $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
+            $book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
+            Activity::add(ActivityType::BOOK_CREATE, $book);
+
+            $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
+            if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
+                $book->sort_rule_id = $defaultBookSortSetting;
+            }
 
-        $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
-        if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
-            $book->sort_rule_id = $defaultBookSortSetting;
             $book->save();
-        }
 
-        return $book;
+            return $book;
+        }))->run();
     }
 
     /**
@@ -48,28 +51,29 @@ class BookRepo
      */
     public function update(Book $book, array $input): Book
     {
-        $this->baseRepo->update($book, $input);
+        $book = $this->baseRepo->update($book, $input);
 
         if (array_key_exists('default_template_id', $input)) {
-            $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id']));
+            $book->defaultTemplate()->setFromId(intval($input['default_template_id']));
         }
 
         if (array_key_exists('image', $input)) {
             $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
         }
 
+        $book->save();
         Activity::add(ActivityType::BOOK_UPDATE, $book);
 
         return $book;
     }
 
     /**
-     * Update the given book's cover image, or clear it.
+     * Update the given book's cover image or clear it.
      *
      * @throws ImageUploadException
      * @throws Exception
      */
-    public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false)
+    public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void
     {
         $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
     }
@@ -79,7 +83,7 @@ class BookRepo
      *
      * @throws Exception
      */
-    public function destroy(Book $book)
+    public function destroy(Book $book): void
     {
         $this->trashCan->softDestroyBook($book);
         Activity::add(ActivityType::BOOK_DELETE, $book);
diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php
index a00349ef1..bb84b51fd 100644
--- a/app/Entities/Repos/BookshelfRepo.php
+++ b/app/Entities/Repos/BookshelfRepo.php
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Facades\Activity;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 
 class BookshelfRepo
@@ -23,13 +24,13 @@ class BookshelfRepo
      */
     public function create(array $input, array $bookIds): Bookshelf
     {
-        $shelf = new Bookshelf();
-        $this->baseRepo->create($shelf, $input);
-        $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
-        $this->updateBooks($shelf, $bookIds);
-        Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
-
-        return $shelf;
+        return (new DatabaseTransaction(function () use ($input, $bookIds) {
+            $shelf = $this->baseRepo->create(new Bookshelf(), $input);
+            $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
+            $this->updateBooks($shelf, $bookIds);
+            Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
+            return $shelf;
+        }))->run();
     }
 
     /**
@@ -37,7 +38,7 @@ class BookshelfRepo
      */
     public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
     {
-        $this->baseRepo->update($shelf, $input);
+        $shelf = $this->baseRepo->update($shelf, $input);
 
         if (!is_null($bookIds)) {
             $this->updateBooks($shelf, $bookIds);
@@ -54,20 +55,37 @@ class BookshelfRepo
 
     /**
      * Update which books are assigned to this shelf by syncing the given book ids.
-     * Function ensures the books are visible to the current user and existing.
+     * Function ensures the managed books are visible to the current user and existing,
+     * and that the user does not alter the assignment of books that are not visible to them.
      */
-    protected function updateBooks(Bookshelf $shelf, array $bookIds)
+    protected function updateBooks(Bookshelf $shelf, array $bookIds): void
     {
         $numericIDs = collect($bookIds)->map(function ($id) {
             return intval($id);
         });
 
-        $syncData = $this->bookQueries->visibleForList()
+        $existingBookIds = $shelf->books()->pluck('id')->toArray();
+        $visibleExistingBookIds = $this->bookQueries->visibleForList()
+            ->whereIn('id', $existingBookIds)
+            ->pluck('id')
+            ->toArray();
+        $nonVisibleExistingBookIds = array_values(array_diff($existingBookIds, $visibleExistingBookIds));
+
+        $newIdsToAssign = $this->bookQueries->visibleForList()
             ->whereIn('id', $bookIds)
             ->pluck('id')
-            ->mapWithKeys(function ($bookId) use ($numericIDs) {
-                return [$bookId => ['order' => $numericIDs->search($bookId)]];
-            });
+            ->toArray();
+
+        $maxNewIndex = max($numericIDs->keys()->toArray() ?: [0]);
+
+        $syncData = [];
+        foreach ($newIdsToAssign as $id) {
+            $syncData[$id] = ['order' => $numericIDs->search($id)];
+        }
+
+        foreach ($nonVisibleExistingBookIds as $index => $id) {
+            $syncData[$id] = ['order' => $maxNewIndex + ($index + 1)];
+        }
 
         $shelf->books()->sync($syncData);
     }
@@ -77,7 +95,7 @@ class BookshelfRepo
      *
      * @throws Exception
      */
-    public function destroy(Bookshelf $shelf)
+    public function destroy(Bookshelf $shelf): void
     {
         $this->trashCan->softDestroyShelf($shelf);
         Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php
index fdf2de4e2..a528eece0 100644
--- a/app/Entities/Repos/ChapterRepo.php
+++ b/app/Entities/Repos/ChapterRepo.php
@@ -7,10 +7,13 @@ use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\ParentChanger;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
+use BookStack\Permissions\Permission;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 
 class ChapterRepo
@@ -19,6 +22,7 @@ class ChapterRepo
         protected BaseRepo $baseRepo,
         protected EntityQueries $entityQueries,
         protected TrashCan $trashCan,
+        protected ParentChanger $parentChanger,
     ) {
     }
 
@@ -27,16 +31,21 @@ class ChapterRepo
      */
     public function create(array $input, Book $parentBook): Chapter
     {
-        $chapter = new Chapter();
-        $chapter->book_id = $parentBook->id;
-        $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
-        $this->baseRepo->create($chapter, $input);
-        $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
-        Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
+        return (new DatabaseTransaction(function () use ($input, $parentBook) {
+            $chapter = new Chapter();
+            $chapter->book_id = $parentBook->id;
+            $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
 
-        $this->baseRepo->sortParent($chapter);
+            $chapter = $this->baseRepo->create($chapter, $input);
+            $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
 
-        return $chapter;
+            $chapter->save();
+            Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
+
+            $this->baseRepo->sortParent($chapter);
+
+            return $chapter;
+        }))->run();
     }
 
     /**
@@ -44,12 +53,13 @@ class ChapterRepo
      */
     public function update(Chapter $chapter, array $input): Chapter
     {
-        $this->baseRepo->update($chapter, $input);
+        $chapter = $this->baseRepo->update($chapter, $input);
 
         if (array_key_exists('default_template_id', $input)) {
-            $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id']));
+            $chapter->defaultTemplate()->setFromId(intval($input['default_template_id']));
         }
 
+        $chapter->save();
         Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
 
         $this->baseRepo->sortParent($chapter);
@@ -62,7 +72,7 @@ class ChapterRepo
      *
      * @throws Exception
      */
-    public function destroy(Chapter $chapter)
+    public function destroy(Chapter $chapter): void
     {
         $this->trashCan->softDestroyChapter($chapter);
         Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
@@ -84,16 +94,18 @@ class ChapterRepo
             throw new MoveOperationException('Book to move chapter into not found');
         }
 
-        if (!userCan('chapter-create', $parent)) {
+        if (!userCan(Permission::ChapterCreate, $parent)) {
             throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
         }
 
-        $chapter->changeBook($parent->id);
-        $chapter->rebuildPermissions();
-        Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
+        return (new DatabaseTransaction(function () use ($chapter, $parent) {
+            $this->parentChanger->changeBook($chapter, $parent->id);
+            $chapter->rebuildPermissions();
+            Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
 
-        $this->baseRepo->sortParent($chapter);
+            $this->baseRepo->sortParent($chapter);
 
-        return $parent;
+            return $parent;
+        }))->run();
     }
 }
diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php
index e47192cc2..5b67e5e6b 100644
--- a/app/Entities/Repos/DeletionRepo.php
+++ b/app/Entities/Repos/DeletionRepo.php
@@ -9,11 +9,9 @@ use BookStack\Facades\Activity;
 
 class DeletionRepo
 {
-    private TrashCan $trashCan;
-
-    public function __construct(TrashCan $trashCan)
-    {
-        $this->trashCan = $trashCan;
+    public function __construct(
+        protected TrashCan $trashCan
+    ) {
     }
 
     public function restore(int $id): int
diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php
index c3be6d826..bc590785d 100644
--- a/app/Entities/Repos/PageRepo.php
+++ b/app/Entities/Repos/PageRepo.php
@@ -12,12 +12,15 @@ use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Tools\PageEditorType;
+use BookStack\Entities\Tools\ParentChanger;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
+use BookStack\Permissions\Permission;
 use BookStack\References\ReferenceStore;
 use BookStack\References\ReferenceUpdater;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 
 class PageRepo
@@ -29,13 +32,14 @@ class PageRepo
         protected ReferenceStore $referenceStore,
         protected ReferenceUpdater $referenceUpdater,
         protected TrashCan $trashCan,
+        protected ParentChanger $parentChanger,
     ) {
     }
 
     /**
      * Get a new draft page belonging to the given parent entity.
      */
-    public function getNewDraftPage(Entity $parent)
+    public function getNewDraftPage(Entity $parent): Page
     {
         $page = (new Page())->forceFill([
             'name'       => trans('entities.pages_initial_name'),
@@ -44,6 +48,9 @@ class PageRepo
             'updated_by' => user()->id,
             'draft'      => true,
             'editor'     => PageEditorType::getSystemDefault()->value,
+            'html'       => '',
+            'markdown'   => '',
+            'text'       => '',
         ]);
 
         if ($parent instanceof Chapter) {
@@ -53,16 +60,19 @@ class PageRepo
             $page->book_id = $parent->id;
         }
 
-        $defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
-        if ($defaultTemplate && userCan('view', $defaultTemplate)) {
+        $defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
+        if ($defaultTemplate) {
             $page->forceFill([
                 'html'  => $defaultTemplate->html,
                 'markdown' => $defaultTemplate->markdown,
             ]);
+            $page->text = (new PageContent($page))->toPlainText();
         }
 
-        $page->save();
-        $page->refresh()->rebuildPermissions();
+        (new DatabaseTransaction(function () use ($page) {
+            $page->save();
+            $page->rebuildPermissions();
+        }))->run();
 
         return $page;
     }
@@ -72,26 +82,30 @@ class PageRepo
      */
     public function publishDraft(Page $draft, array $input): Page
     {
-        $draft->draft = false;
-        $draft->revision_count = 1;
-        $draft->priority = $this->getNewPriority($draft);
-        $this->updateTemplateStatusAndContentFromInput($draft, $input);
-        $this->baseRepo->update($draft, $input);
+        return (new DatabaseTransaction(function () use ($draft, $input) {
+            $draft->draft = false;
+            $draft->revision_count = 1;
+            $draft->priority = $this->getNewPriority($draft);
+            $this->updateTemplateStatusAndContentFromInput($draft, $input);
 
-        $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
-        $this->revisionRepo->storeNewForPage($draft, $summary);
-        $draft->refresh();
+            $draft = $this->baseRepo->update($draft, $input);
+            $draft->rebuildPermissions();
 
-        Activity::add(ActivityType::PAGE_CREATE, $draft);
-        $this->baseRepo->sortParent($draft);
+            $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
+            $this->revisionRepo->storeNewForPage($draft, $summary);
+            $draft->refresh();
 
-        return $draft;
+            Activity::add(ActivityType::PAGE_CREATE, $draft);
+            $this->baseRepo->sortParent($draft);
+
+            return $draft;
+        }))->run();
     }
 
     /**
      * Directly update the content for the given page from the provided input.
      * Used for direct content access in a way that performs required changes
-     * (Search index & reference regen) without performing an official update.
+     * (Search index and reference regen) without performing an official update.
      */
     public function setContentFromInput(Page $page, array $input): void
     {
@@ -105,18 +119,18 @@ class PageRepo
     public function update(Page $page, array $input): Page
     {
         // Hold the old details to compare later
-        $oldHtml = $page->html;
         $oldName = $page->name;
+        $oldHtml = $page->html;
         $oldMarkdown = $page->markdown;
 
         $this->updateTemplateStatusAndContentFromInput($page, $input);
-        $this->baseRepo->update($page, $input);
+        $page = $this->baseRepo->update($page, $input);
 
         // Update with new details
         $page->revision_count++;
         $page->save();
 
-        // Remove all update drafts for this user & page.
+        // Remove all update drafts for this user and page.
         $this->revisionRepo->deleteDraftsForCurrentUser($page);
 
         // Save a revision after updating
@@ -136,7 +150,7 @@ class PageRepo
 
     protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void
     {
-        if (isset($input['template']) && userCan('templates-manage')) {
+        if (isset($input['template']) && userCan(Permission::TemplatesManage)) {
             $page->template = ($input['template'] === 'true');
         }
 
@@ -159,7 +173,7 @@ class PageRepo
             $pageContent->setNewHTML($input['html'], user());
         }
 
-        if (($newEditor !== $currentEditor || empty($page->editor)) && userCan('editor-change')) {
+        if (($newEditor !== $currentEditor || empty($page->editor)) && userCan(Permission::EditorChange)) {
             $page->editor = $newEditor->value;
         } elseif (empty($page->editor)) {
             $page->editor = $defaultEditor->value;
@@ -169,12 +183,12 @@ class PageRepo
     /**
      * Save a page update draft.
      */
-    public function updatePageDraft(Page $page, array $input)
+    public function updatePageDraft(Page $page, array $input): Page|PageRevision
     {
-        // If the page itself is a draft simply update that
+        // If the page itself is a draft, simply update that
         if ($page->draft) {
             $this->updateTemplateStatusAndContentFromInput($page, $input);
-            $page->fill($input);
+            $page->forceFill(array_intersect_key($input, array_flip(['name'])))->save();
             $page->save();
 
             return $page;
@@ -202,7 +216,7 @@ class PageRepo
      *
      * @throws Exception
      */
-    public function destroy(Page $page)
+    public function destroy(Page $page): void
     {
         $this->trashCan->softDestroyPage($page);
         Activity::add(ActivityType::PAGE_DELETE, $page);
@@ -230,7 +244,7 @@ class PageRepo
         }
 
         $page->updated_by = user()->id;
-        $page->refreshSlug();
+        $this->baseRepo->refreshSlug($page);
         $page->save();
         $page->indexForSearch();
         $this->referenceStore->updateForEntity($page);
@@ -265,20 +279,22 @@ class PageRepo
             throw new MoveOperationException('Book or chapter to move page into not found');
         }
 
-        if (!userCan('page-create', $parent)) {
+        if (!userCan(Permission::PageCreate, $parent)) {
             throw new PermissionsException('User does not have permission to create a page within the new parent');
         }
 
-        $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
-        $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
-        $page->changeBook($newBookId);
-        $page->rebuildPermissions();
+        return (new DatabaseTransaction(function () use ($page, $parent) {
+            $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
+            $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
+            $this->parentChanger->changeBook($page, $newBookId);
+            $page->rebuildPermissions();
 
-        Activity::add(ActivityType::PAGE_MOVE, $page);
+            Activity::add(ActivityType::PAGE_MOVE, $page);
 
-        $this->baseRepo->sortParent($page);
+            $this->baseRepo->sortParent($page);
 
-        return $parent;
+            return $parent;
+        }))->run();
     }
 
     /**
diff --git a/app/Entities/Repos/RevisionRepo.php b/app/Entities/Repos/RevisionRepo.php
index d5549a0f1..2d1371b63 100644
--- a/app/Entities/Repos/RevisionRepo.php
+++ b/app/Entities/Repos/RevisionRepo.php
@@ -23,7 +23,7 @@ class RevisionRepo
 
     /**
      * Get a user update_draft page revision to update for the given page.
-     * Checks for an existing revisions before providing a fresh one.
+     * Checks for an existing revision before providing a fresh one.
      */
     public function getNewDraftForCurrentUser(Page $page): PageRevision
     {
@@ -72,7 +72,7 @@ class RevisionRepo
     /**
      * Delete old revisions, for the given page, from the system.
      */
-    protected function deleteOldRevisions(Page $page)
+    protected function deleteOldRevisions(Page $page): void
     {
         $revisionLimit = config('app.revision_limit');
         if ($revisionLimit === false) {
diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php
index 7dd3f3e11..4bbab6265 100644
--- a/app/Entities/Tools/BookContents.php
+++ b/app/Entities/Tools/BookContents.php
@@ -3,13 +3,10 @@
 namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\BookChild;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Queries\EntityQueries;
-use BookStack\Sorting\BookSortMap;
-use BookStack\Sorting\BookSortMapItem;
 use Illuminate\Support\Collection;
 
 class BookContents
@@ -29,7 +26,7 @@ class BookContents
     {
         $maxPage = $this->book->pages()
             ->where('draft', '=', false)
-            ->where('chapter_id', '=', 0)
+            ->whereDoesntHave('chapter')
             ->max('priority');
 
         $maxChapter = $this->book->chapters()
@@ -80,11 +77,11 @@ class BookContents
     protected function bookChildSortFunc(): callable
     {
         return function (Entity $entity) {
-            if (isset($entity['draft']) && $entity['draft']) {
+            if ($entity->getAttribute('draft') ?? false) {
                 return -100;
             }
 
-            return $entity['priority'] ?? 0;
+            return $entity->getAttribute('priority') ?? 0;
         };
     }
 
diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php
index 2be6083e3..64c48c351 100644
--- a/app/Entities/Tools/Cloner.php
+++ b/app/Entities/Tools/Cloner.php
@@ -6,36 +6,54 @@ use BookStack\Activity\Models\Tag;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\HasCoverInterface;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\HasCoverImage;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Repos\ChapterRepo;
 use BookStack\Entities\Repos\PageRepo;
+use BookStack\Permissions\Permission;
+use BookStack\References\ReferenceChangeContext;
+use BookStack\References\ReferenceUpdater;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageService;
 use Illuminate\Http\UploadedFile;
 
 class Cloner
 {
+    protected ReferenceChangeContext $referenceChangeContext;
+
     public function __construct(
         protected PageRepo $pageRepo,
         protected ChapterRepo $chapterRepo,
         protected BookRepo $bookRepo,
         protected ImageService $imageService,
+        protected ReferenceUpdater $referenceUpdater,
     ) {
+        $this->referenceChangeContext = new ReferenceChangeContext();
     }
 
     /**
      * Clone the given page into the given parent using the provided name.
      */
     public function clonePage(Page $original, Entity $parent, string $newName): Page
+    {
+        $context = $this->newReferenceChangeContext();
+        $page = $this->createPageClone($original, $parent, $newName);
+        $this->referenceUpdater->changeReferencesUsingContext($context);
+        return $page;
+    }
+
+    protected function createPageClone(Page $original, Entity $parent, string $newName): Page
     {
         $copyPage = $this->pageRepo->getNewDraftPage($parent);
         $pageData = $this->entityToInputData($original);
         $pageData['name'] = $newName;
 
-        return $this->pageRepo->publishDraft($copyPage, $pageData);
+        $newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
+        $this->referenceChangeContext->add($original, $newPage);
+
+        return $newPage;
     }
 
     /**
@@ -43,27 +61,45 @@ class Cloner
      * Clones all child pages.
      */
     public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
+    {
+        $context = $this->newReferenceChangeContext();
+        $chapter = $this->createChapterClone($original, $parent, $newName);
+        $this->referenceUpdater->changeReferencesUsingContext($context);
+        return $chapter;
+    }
+
+    protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
     {
         $chapterDetails = $this->entityToInputData($original);
         $chapterDetails['name'] = $newName;
 
         $copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
 
-        if (userCan('page-create', $copyChapter)) {
+        if (userCan(Permission::PageCreate, $copyChapter)) {
             /** @var Page $page */
             foreach ($original->getVisiblePages() as $page) {
-                $this->clonePage($page, $copyChapter, $page->name);
+                $this->createPageClone($page, $copyChapter, $page->name);
             }
         }
 
+        $this->referenceChangeContext->add($original, $copyChapter);
+
         return $copyChapter;
     }
 
     /**
      * Clone the given book.
-     * Clones all child chapters & pages.
+     * Clones all child chapters and pages.
      */
     public function cloneBook(Book $original, string $newName): Book
+    {
+        $context = $this->newReferenceChangeContext();
+        $book = $this->createBookClone($original, $newName);
+        $this->referenceUpdater->changeReferencesUsingContext($context);
+        return $book;
+    }
+
+    protected function createBookClone(Book $original, string $newName): Book
     {
         $bookDetails = $this->entityToInputData($original);
         $bookDetails['name'] = $newName;
@@ -74,23 +110,25 @@ class Cloner
         // Clone contents
         $directChildren = $original->getDirectVisibleChildren();
         foreach ($directChildren as $child) {
-            if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
-                $this->cloneChapter($child, $copyBook, $child->name);
+            if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
+                $this->createChapterClone($child, $copyBook, $child->name);
             }
 
-            if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) {
-                $this->clonePage($child, $copyBook, $child->name);
+            if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
+                $this->createPageClone($child, $copyBook, $child->name);
             }
         }
 
         // Clone bookshelf relationships
         /** @var Bookshelf $shelf */
         foreach ($original->shelves as $shelf) {
-            if (userCan('bookshelf-update', $shelf)) {
+            if (userCan(Permission::BookshelfUpdate, $shelf)) {
                 $shelf->appendBook($copyBook);
             }
         }
 
+        $this->referenceChangeContext->add($original, $copyBook);
+
         return $copyBook;
     }
 
@@ -105,8 +143,8 @@ class Cloner
         $inputData['tags'] = $this->entityTagsToInputArray($entity);
 
         // Add a cover to the data if existing on the original entity
-        if ($entity instanceof HasCoverImage) {
-            $cover = $entity->cover()->first();
+        if ($entity instanceof HasCoverInterface) {
+            $cover = $entity->coverInfo()->getImage();
             if ($cover) {
                 $inputData['image'] = $this->imageToUploadedFile($cover);
             }
@@ -154,4 +192,10 @@ class Cloner
 
         return $tags;
     }
+
+    protected function newReferenceChangeContext(): ReferenceChangeContext
+    {
+        $this->referenceChangeContext = new ReferenceChangeContext();
+        return $this->referenceChangeContext;
+    }
 }
diff --git a/app/Entities/Tools/EntityCover.php b/app/Entities/Tools/EntityCover.php
new file mode 100644
index 000000000..1e8fce201
--- /dev/null
+++ b/app/Entities/Tools/EntityCover.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Uploads\Image;
+use Exception;
+use Illuminate\Database\Eloquent\Builder;
+
+class EntityCover
+{
+    public function __construct(
+        protected Book|Bookshelf $entity,
+    ) {
+    }
+
+    protected function imageQuery(): Builder
+    {
+        return Image::query()->where('id', '=', $this->entity->image_id);
+    }
+
+    /**
+     * Check if a cover image exists for this entity.
+     */
+    public function exists(): bool
+    {
+        return $this->entity->image_id !== null && $this->imageQuery()->exists();
+    }
+
+    /**
+     * Get the assigned cover image model.
+     */
+    public function getImage(): Image|null
+    {
+        if ($this->entity->image_id === null) {
+            return null;
+        }
+
+        $cover = $this->imageQuery()->first();
+        if ($cover instanceof Image) {
+            return $cover;
+        }
+
+        return null;
+    }
+
+    /**
+     * Returns a cover image URL, or the given default if none assigned/existing.
+     */
+    public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null
+    {
+        if (!$this->entity->image_id) {
+            return $default;
+        }
+
+        try {
+            return $this->getImage()?->getThumb($width, $height, false) ?? $default;
+        } catch (Exception $err) {
+            return $default;
+        }
+    }
+
+    /**
+     * Set the image to use as the cover for this entity.
+     */
+    public function setImage(Image|null $image): void
+    {
+        if ($image === null) {
+            $this->entity->image_id = null;
+        } else {
+            $this->entity->image_id = $image->id;
+        }
+    }
+}
diff --git a/app/Entities/Tools/EntityDefaultTemplate.php b/app/Entities/Tools/EntityDefaultTemplate.php
new file mode 100644
index 000000000..d36c3f270
--- /dev/null
+++ b/app/Entities/Tools/EntityDefaultTemplate.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Queries\PageQueries;
+
+class EntityDefaultTemplate
+{
+    public function __construct(
+        protected Book|Chapter $entity,
+    ) {
+    }
+
+    /**
+     * Set the default template ID for this entity.
+     */
+    public function setFromId(int $templateId): void
+    {
+        $changing = $templateId !== intval($this->entity->default_template_id);
+        if (!$changing) {
+            return;
+        }
+
+        if ($templateId === 0) {
+            $this->entity->default_template_id = null;
+            return;
+        }
+
+        $pageQueries = app()->make(PageQueries::class);
+        $templateExists = $pageQueries->visibleTemplates()
+            ->where('id', '=', $templateId)
+            ->exists();
+
+        $this->entity->default_template_id = $templateExists ? $templateId : null;
+    }
+
+    /**
+     * Get the default template for this entity (if visible).
+     */
+    public function get(): Page|null
+    {
+        if (!$this->entity->default_template_id) {
+            return null;
+        }
+
+        $pageQueries = app()->make(PageQueries::class);
+        $page = $pageQueries->visibleTemplates(true)
+            ->where('id', '=', $this->entity->default_template_id)
+            ->first();
+
+        if ($page instanceof Page) {
+            return $page;
+        }
+
+        return null;
+    }
+}
diff --git a/app/Entities/Tools/EntityHtmlDescription.php b/app/Entities/Tools/EntityHtmlDescription.php
new file mode 100644
index 000000000..335703c36
--- /dev/null
+++ b/app/Entities/Tools/EntityHtmlDescription.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Util\HtmlContentFilter;
+
+class EntityHtmlDescription
+{
+    protected string $html = '';
+    protected string $plain = '';
+
+    public function __construct(
+        protected Book|Chapter|Bookshelf $entity,
+    ) {
+        $this->html = $this->entity->description_html ?? '';
+        $this->plain = $this->entity->description ?? '';
+    }
+
+    /**
+     * Update the description from HTML code.
+     * Optionally takes plaintext to use for the model also.
+     */
+    public function set(string $html, string|null $plaintext = null): void
+    {
+        $this->html = $html;
+        $this->entity->description_html = $this->html;
+
+        if ($plaintext !== null) {
+            $this->plain = $plaintext;
+            $this->entity->description = $this->plain;
+        }
+
+        if (empty($html) && !empty($plaintext)) {
+            $this->html = $this->getHtml();
+            $this->entity->description_html = $this->html;
+        }
+    }
+
+    /**
+     * Get the description as HTML.
+     * Optionally returns the raw HTML if requested.
+     */
+    public function getHtml(bool $raw = false): string
+    {
+        $html = $this->html ?: '<p>' . nl2br(e($this->plain)) . '</p>';
+        if ($raw) {
+            return $html;
+        }
+
+        return HtmlContentFilter::removeScriptsFromHtmlString($html);
+    }
+
+    public function getPlain(): string
+    {
+        return $this->plain;
+    }
+}
diff --git a/app/Entities/Tools/EntityHydrator.php b/app/Entities/Tools/EntityHydrator.php
new file mode 100644
index 000000000..87e39d222
--- /dev/null
+++ b/app/Entities/Tools/EntityHydrator.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Activity\Models\Tag;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\EntityTable;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Queries\EntityQueries;
+use Illuminate\Database\Eloquent\Collection;
+
+class EntityHydrator
+{
+    public function __construct(
+        protected EntityQueries $entityQueries,
+    ) {
+    }
+
+    /**
+     * Hydrate the entities of this hydrator to return a list of entities represented
+     * in their original intended models.
+     * @param EntityTable[] $entities
+     * @return Entity[]
+     */
+    public function hydrate(array $entities, bool $loadTags = false, bool $loadParents = false): array
+    {
+        $hydrated = [];
+
+        foreach ($entities as $entity) {
+            $data = $entity->getRawOriginal();
+            $instance = Entity::instanceFromType($entity->type);
+
+            if ($instance instanceof Page) {
+                $data['text'] = $data['description'];
+                unset($data['description']);
+            }
+
+            $instance = $instance->setRawAttributes($data, true);
+            $hydrated[] = $instance;
+        }
+
+        if ($loadTags) {
+            $this->loadTagsIntoModels($hydrated);
+        }
+
+        if ($loadParents) {
+            $this->loadParentsIntoModels($hydrated);
+        }
+
+        return $hydrated;
+    }
+
+    /**
+     * @param Entity[] $entities
+     */
+    protected function loadTagsIntoModels(array $entities): void
+    {
+        $idsByType = [];
+        $entityMap = [];
+        foreach ($entities as $entity) {
+            if (!isset($idsByType[$entity->type])) {
+                $idsByType[$entity->type] = [];
+            }
+            $idsByType[$entity->type][] = $entity->id;
+            $entityMap[$entity->type . ':' . $entity->id] = $entity;
+        }
+
+        $query = Tag::query();
+        foreach ($idsByType as $type => $ids) {
+            $query->orWhere(function ($query) use ($type, $ids) {
+                $query->where('entity_type', '=', $type)
+                    ->whereIn('entity_id', $ids);
+            });
+        }
+
+        $tags = empty($idsByType) ? [] : $query->get()->all();
+        $tagMap = [];
+        foreach ($tags as $tag) {
+            $key = $tag->entity_type . ':' . $tag->entity_id;
+            if (!isset($tagMap[$key])) {
+                $tagMap[$key] = [];
+            }
+            $tagMap[$key][] = $tag;
+        }
+
+        foreach ($entityMap as $key => $entity) {
+            $entityTags = new Collection($tagMap[$key] ?? []);
+            $entity->setRelation('tags', $entityTags);
+        }
+    }
+
+    /**
+     * @param Entity[] $entities
+     */
+    protected function loadParentsIntoModels(array $entities): void
+    {
+        $parentsByType = ['book' => [], 'chapter' => []];
+
+        foreach ($entities as $entity) {
+            if ($entity->getAttribute('book_id') !== null) {
+                $parentsByType['book'][] = $entity->getAttribute('book_id');
+            }
+            if ($entity->getAttribute('chapter_id') !== null) {
+                $parentsByType['chapter'][] = $entity->getAttribute('chapter_id');
+            }
+        }
+
+        $parentQuery = $this->entityQueries->visibleForList();
+        $filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0;
+        $parentQuery = $parentQuery->where(function ($query) use ($parentsByType) {
+            foreach ($parentsByType as $type => $ids) {
+                if (count($ids) > 0) {
+                    $query = $query->orWhere(function ($query) use ($type, $ids) {
+                        $query->where('type', '=', $type)
+                            ->whereIn('id', $ids);
+                    });
+                }
+            }
+        });
+
+        $parentModels = $filtered ? $parentQuery->get()->all() : [];
+        $parents = $this->hydrate($parentModels);
+        $parentMap = [];
+        foreach ($parents as $parent) {
+            $parentMap[$parent->type . ':' . $parent->id] = $parent;
+        }
+
+        foreach ($entities as $entity) {
+            if ($entity instanceof Page || $entity instanceof Chapter) {
+                $key = 'book:' . $entity->getRawAttribute('book_id');
+                $entity->setRelation('book', $parentMap[$key] ?? null);
+            }
+            if ($entity instanceof Page) {
+                $key = 'chapter:' . $entity->getRawAttribute('chapter_id');
+                $entity->setRelation('chapter', $parentMap[$key] ?? null);
+            }
+        }
+    }
+}
diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php
index cd6c548fe..c58d29bd0 100644
--- a/app/Entities/Tools/HierarchyTransformer.php
+++ b/app/Entities/Tools/HierarchyTransformer.php
@@ -13,17 +13,13 @@ use BookStack\Facades\Activity;
 
 class HierarchyTransformer
 {
-    protected BookRepo $bookRepo;
-    protected BookshelfRepo $shelfRepo;
-    protected Cloner $cloner;
-    protected TrashCan $trashCan;
-
-    public function __construct(BookRepo $bookRepo, BookshelfRepo $shelfRepo, Cloner $cloner, TrashCan $trashCan)
-    {
-        $this->bookRepo = $bookRepo;
-        $this->shelfRepo = $shelfRepo;
-        $this->cloner = $cloner;
-        $this->trashCan = $trashCan;
+    public function __construct(
+        protected BookRepo $bookRepo,
+        protected BookshelfRepo $shelfRepo,
+        protected Cloner $cloner,
+        protected TrashCan $trashCan,
+        protected ParentChanger $parentChanger,
+    ) {
     }
 
     /**
@@ -39,7 +35,8 @@ class HierarchyTransformer
         /** @var Page $page */
         foreach ($chapter->pages as $page) {
             $page->chapter_id = 0;
-            $page->changeBook($book->id);
+            $page->save();
+            $this->parentChanger->changeBook($page, $book->id);
         }
 
         $this->trashCan->destroyEntity($chapter);
diff --git a/app/Entities/Tools/MixedEntityListLoader.php b/app/Entities/Tools/MixedEntityListLoader.php
index f9a940b98..9987cc061 100644
--- a/app/Entities/Tools/MixedEntityListLoader.php
+++ b/app/Entities/Tools/MixedEntityListLoader.php
@@ -19,7 +19,7 @@ class MixedEntityListLoader
      * This will look for a model id and type via 'name_id' and 'name_type'.
      * @param Model[] $relations
      */
-    public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void
+    public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void
     {
         $idsByType = [];
         foreach ($relations as $relation) {
@@ -33,7 +33,7 @@ class MixedEntityListLoader
             $idsByType[$type][] = $id;
         }
 
-        $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
+        $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents);
 
         foreach ($relations as $relation) {
             $type = $relation->getAttribute($relationName . '_type');
@@ -49,13 +49,13 @@ class MixedEntityListLoader
      * @param array<string, int[]> $idsByType
      * @return array<string, array<int, Model>>
      */
-    protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array
+    protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array
     {
         $modelMap = [];
 
         foreach ($idsByType as $type => $ids) {
-            $models = $this->queries->visibleForList($type)
-                ->whereIn('id', $ids)
+            $base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type);
+            $models = $base->whereIn('id', $ids)
                 ->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
                 ->get();
 
diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php
index d2f5de65c..c7a59216a 100644
--- a/app/Entities/Tools/PageContent.php
+++ b/app/Entities/Tools/PageContent.php
@@ -7,6 +7,7 @@ use BookStack\Entities\Queries\PageQueries;
 use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Facades\Theme;
+use BookStack\Permissions\Permission;
 use BookStack\Theming\ThemeEvents;
 use BookStack\Uploads\ImageRepo;
 use BookStack\Uploads\ImageService;
@@ -122,7 +123,7 @@ class PageContent
         $imageInfo = $this->parseBase64ImageUri($uri);
 
         // Validate user has permission to create images
-        if (!$updater->can('image-create-all')) {
+        if (!$updater->can(Permission::ImageCreateAll)) {
             return '';
         }
 
@@ -283,7 +284,7 @@ class PageContent
     /**
      * Get a plain-text visualisation of this page.
      */
-    protected function toPlainText(): string
+    public function toPlainText(): string
     {
         $html = $this->render(true);
 
diff --git a/app/Entities/Tools/PageEditActivity.php b/app/Entities/Tools/PageEditActivity.php
index 646b200f1..22f89bf62 100644
--- a/app/Entities/Tools/PageEditActivity.php
+++ b/app/Entities/Tools/PageEditActivity.php
@@ -4,19 +4,15 @@ namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Models\PageRevision;
+use BookStack\Util\DateFormatter;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Builder;
 
 class PageEditActivity
 {
-    protected Page $page;
-
-    /**
-     * PageEditActivity constructor.
-     */
-    public function __construct(Page $page)
-    {
-        $this->page = $page;
+    public function __construct(
+        protected Page $page
+    ) {
     }
 
     /**
@@ -50,11 +46,9 @@ class PageEditActivity
     /**
      * Get any editor clash warning messages to show for the given draft revision.
      *
-     * @param PageRevision|Page $draft
-     *
      * @return string[]
      */
-    public function getWarningMessagesForDraft($draft): array
+    public function getWarningMessagesForDraft(Page|PageRevision $draft): array
     {
         $warnings = [];
 
@@ -82,7 +76,8 @@ class PageEditActivity
      */
     public function getEditingActiveDraftMessage(PageRevision $draft): string
     {
-        $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
+        $formatter = resolve(DateFormatter::class);
+        $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $formatter->relative($draft->updated_at)]);
         if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
             return $message;
         }
diff --git a/app/Entities/Tools/PageEditorData.php b/app/Entities/Tools/PageEditorData.php
index e4fe2fd25..b41b31909 100644
--- a/app/Entities/Tools/PageEditorData.php
+++ b/app/Entities/Tools/PageEditorData.php
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Page;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
 use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
+use BookStack\Permissions\Permission;
 
 class PageEditorData
 {
@@ -98,9 +99,9 @@ class PageEditorData
     {
         $editorType = PageEditorType::forPage($page) ?: PageEditorType::getSystemDefault();
 
-        // Use requested editor if valid and if we have permission
+        // Use the requested editor if valid and if we have permission
         $requestedType = PageEditorType::fromRequestValue($this->requestedEditor);
-        if ($requestedType && userCan('editor-change')) {
+        if ($requestedType && userCan(Permission::EditorChange)) {
             $editorType = $requestedType;
         }
 
diff --git a/app/Entities/Tools/PageIncludeParser.php b/app/Entities/Tools/PageIncludeParser.php
index e0b89f158..af7ed4fc6 100644
--- a/app/Entities/Tools/PageIncludeParser.php
+++ b/app/Entities/Tools/PageIncludeParser.php
@@ -7,15 +7,14 @@ use Closure;
 use DOMDocument;
 use DOMElement;
 use DOMNode;
-use DOMText;
 
 class PageIncludeParser
 {
     protected static string $includeTagRegex = "/{{@\s?([0-9].*?)}}/";
 
     /**
-     * Elements to clean up and remove if left empty after a parsing operation.
-     * @var DOMElement[]
+     * Nodes to clean up and remove if left empty after a parsing operation.
+     * @var DOMNode[]
      */
     protected array $toCleanup = [];
 
@@ -159,7 +158,7 @@ class PageIncludeParser
 
     /**
      * Splits the given $parentNode at the location of the $domNode within it.
-     * Attempts replicate the original $parentNode, moving some of their parent
+     * Attempts to replicate the original $parentNode, moving some of their parent
      * children in where needed, before adding the $domNode between.
      */
     protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode): void
@@ -171,6 +170,10 @@ class PageIncludeParser
         }
 
         $parentClone = $parentNode->cloneNode();
+        if (!($parentClone instanceof DOMElement)) {
+            return;
+        }
+
         $parentNode->parentNode->insertBefore($parentClone, $parentNode);
         $parentClone->removeAttribute('id');
 
@@ -203,7 +206,7 @@ class PageIncludeParser
     }
 
     /**
-     * Cleanup after a parse operation.
+     * Clean up after a parse operation.
      * Removes stranded elements we may have left during the parse.
      */
     protected function cleanup(): void
diff --git a/app/Entities/Tools/ParentChanger.php b/app/Entities/Tools/ParentChanger.php
new file mode 100644
index 000000000..00ce42aae
--- /dev/null
+++ b/app/Entities/Tools/ParentChanger.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Chapter;
+use BookStack\References\ReferenceUpdater;
+
+class ParentChanger
+{
+    public function __construct(
+        protected SlugGenerator $slugGenerator,
+        protected ReferenceUpdater $referenceUpdater
+    ) {
+    }
+
+    /**
+     * Change the parent book of a chapter or page.
+     */
+    public function changeBook(BookChild $child, int $newBookId): void
+    {
+        $oldUrl = $child->getUrl();
+
+        $child->book_id = $newBookId;
+        $child->unsetRelation('book');
+        $this->slugGenerator->regenerateForEntity($child);
+        $child->save();
+
+        if ($oldUrl !== $child->getUrl()) {
+            $this->referenceUpdater->updateEntityReferences($child, $oldUrl);
+        }
+
+        // Update all child pages if a chapter
+        if ($child instanceof Chapter) {
+            foreach ($child->pages()->withTrashed()->get() as $page) {
+                $this->changeBook($page, $newBookId);
+            }
+        }
+    }
+}
diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php
index 9f3b8f952..fa9ae753c 100644
--- a/app/Entities/Tools/PermissionsUpdater.php
+++ b/app/Entities/Tools/PermissionsUpdater.php
@@ -8,6 +8,7 @@ use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Entity;
 use BookStack\Facades\Activity;
 use BookStack\Permissions\Models\EntityPermission;
+use BookStack\Permissions\Permission;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
 use Illuminate\Http\Request;
@@ -93,8 +94,9 @@ class PermissionsUpdater
 
         foreach ($permissions as $roleId => $info) {
             $entityPermissionData = ['role_id' => $roleId];
-            foreach (EntityPermission::PERMISSIONS as $permission) {
-                $entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
+            foreach (Permission::genericForEntity() as $permission) {
+                $permName = $permission->value;
+                $entityPermissionData[$permName] = (($info[$permName] ?? false) === "true");
             }
             $formatted[] = $entityPermissionData;
         }
@@ -108,8 +110,9 @@ class PermissionsUpdater
 
         foreach ($permissions as $requestPermissionData) {
             $entityPermissionData = ['role_id' => $requestPermissionData['role_id']];
-            foreach (EntityPermission::PERMISSIONS as $permission) {
-                $entityPermissionData[$permission] = boolval($requestPermissionData[$permission] ?? false);
+            foreach (Permission::genericForEntity() as $permission) {
+                $permName = $permission->value;
+                $entityPermissionData[$permName] = boolval($requestPermissionData[$permName] ?? false);
             }
             $formatted[] = $entityPermissionData;
         }
@@ -147,7 +150,7 @@ class PermissionsUpdater
 
         /** @var Book $book */
         foreach ($shelfBooks as $book) {
-            if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
+            if ($checkUserPermissions && !userCan(Permission::RestrictionsManage, $book)) {
                 continue;
             }
             $book->permissions()->delete();
diff --git a/app/Entities/Tools/SlugGenerator.php b/app/Entities/Tools/SlugGenerator.php
index 5df300bb0..6eec84a91 100644
--- a/app/Entities/Tools/SlugGenerator.php
+++ b/app/Entities/Tools/SlugGenerator.php
@@ -3,19 +3,21 @@
 namespace BookStack\Entities\Tools;
 
 use BookStack\App\Model;
-use BookStack\App\Sluggable;
+use BookStack\App\SluggableInterface;
 use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Entity;
+use BookStack\Users\Models\User;
 use Illuminate\Support\Str;
 
 class SlugGenerator
 {
     /**
-     * Generate a fresh slug for the given entity.
+     * Generate a fresh slug for the given item.
      * The slug will be generated so that it doesn't conflict within the same parent item.
      */
-    public function generate(Sluggable $model): string
+    public function generate(SluggableInterface&Model $model, string $slugSource): string
     {
-        $slug = $this->formatNameAsSlug($model->name);
+        $slug = $this->formatNameAsSlug($slugSource);
         while ($this->slugInUse($slug, $model)) {
             $slug .= '-' . Str::random(3);
         }
@@ -24,7 +26,27 @@ class SlugGenerator
     }
 
     /**
-     * Format a name as a url slug.
+     * Regenerate the slug for the given entity.
+     */
+    public function regenerateForEntity(Entity $entity): string
+    {
+        $entity->slug = $this->generate($entity, $entity->name);
+
+        return $entity->slug;
+    }
+
+    /**
+     * Regenerate the slug for a user.
+     */
+    public function regenerateForUser(User $user): string
+    {
+        $user->slug = $this->generate($user, $user->name);
+
+        return $user->slug;
+    }
+
+    /**
+     * Format a name as a URL slug.
      */
     protected function formatNameAsSlug(string $name): string
     {
@@ -39,10 +61,8 @@ class SlugGenerator
     /**
      * Check if a slug is already in-use for this
      * type of model within the same parent.
-     *
-     * @param Sluggable&Model $model
      */
-    protected function slugInUse(string $slug, Sluggable $model): bool
+    protected function slugInUse(string $slug, SluggableInterface&Model $model): bool
     {
         $query = $model->newQuery()->where('slug', '=', $slug);
 
diff --git a/app/Entities/Tools/SlugHistory.php b/app/Entities/Tools/SlugHistory.php
new file mode 100644
index 000000000..2c8d88129
--- /dev/null
+++ b/app/Entities/Tools/SlugHistory.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\EntityTable;
+use BookStack\Entities\Models\SlugHistory as SlugHistoryModel;
+use BookStack\Permissions\PermissionApplicator;
+use Illuminate\Support\Facades\DB;
+
+class SlugHistory
+{
+    public function __construct(
+        protected PermissionApplicator $permissions,
+    ) {
+    }
+
+    /**
+     * Record the current slugs for the given entity.
+     */
+    public function recordForEntity(Entity $entity): void
+    {
+        if (!$entity->id || !$entity->slug) {
+            return;
+        }
+
+        $parentSlug = null;
+        if ($entity instanceof BookChild) {
+            $parentSlug = $entity->book()->first()?->slug;
+        }
+
+        $latest = $this->getLatestEntryForEntity($entity);
+        if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) {
+            return;
+        }
+
+        $info = [
+            'sluggable_type' => $entity->getMorphClass(),
+            'sluggable_id'   => $entity->id,
+            'slug'           => $entity->slug,
+            'parent_slug'    => $parentSlug,
+        ];
+
+        $entry = new SlugHistoryModel();
+        $entry->forceFill($info);
+        $entry->save();
+
+        if ($entity instanceof Book) {
+            $this->recordForBookChildren($entity);
+        }
+    }
+
+    protected function recordForBookChildren(Book $book): void
+    {
+        $query = EntityTable::query()
+            ->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')])
+            ->where('book_id', '=', $book->id)
+            ->whereNotNull('book_id');
+
+        SlugHistoryModel::query()->insertUsing(
+            ['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
+            $query
+        );
+    }
+
+    /**
+     * Find the latest visible entry for an entity which uses the given slug(s) in the history.
+     */
+    public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int
+    {
+        $query = SlugHistoryModel::query()
+            ->where('sluggable_type', '=', $type)
+            ->where('slug', '=', $slug);
+
+        if ($parentSlug) {
+            $query->where('parent_slug', '=', $parentSlug);
+        }
+
+        $query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type');
+
+        /** @var SlugHistoryModel|null $result */
+        $result = $query->orderBy('created_at', 'desc')->first();
+
+        return $result?->sluggable_id;
+    }
+
+    protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null
+    {
+        return SlugHistoryModel::query()
+            ->where('sluggable_type', '=', $entity->getMorphClass())
+            ->where('sluggable_id', '=', $entity->id)
+            ->orderBy('created_at', 'desc')
+            ->first();
+    }
+}
diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php
index 39c982cdc..96645aebf 100644
--- a/app/Entities/Tools/TrashCan.php
+++ b/app/Entities/Tools/TrashCan.php
@@ -6,15 +6,18 @@ use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\EntityContainerData;
+use BookStack\Entities\Models\HasCoverInterface;
 use BookStack\Entities\Models\Deletion;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\HasCoverImage;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Exceptions\NotifyException;
 use BookStack\Facades\Activity;
 use BookStack\Uploads\AttachmentService;
+use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageService;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Carbon;
@@ -139,6 +142,7 @@ class TrashCan
     protected function destroyShelf(Bookshelf $shelf): int
     {
         $this->destroyCommonRelations($shelf);
+        $shelf->books()->detach();
         $shelf->forceDelete();
 
         return 1;
@@ -166,6 +170,7 @@ class TrashCan
         }
 
         $this->destroyCommonRelations($book);
+        $book->shelves()->detach();
         $book->forceDelete();
 
         return $count + 1;
@@ -208,15 +213,16 @@ class TrashCan
             $attachmentService->deleteFile($attachment);
         }
 
-        // Remove book template usages
-        $this->queries->books->start()
+        // Remove use as a template
+        EntityContainerData::query()
             ->where('default_template_id', '=', $page->id)
             ->update(['default_template_id' => null]);
 
-        // Remove chapter template usages
-        $this->queries->chapters->start()
-            ->where('default_template_id', '=', $page->id)
-            ->update(['default_template_id' => null]);
+        // Nullify uploaded image relations
+        Image::query()
+            ->whereIn('type', ['gallery', 'drawio'])
+            ->where('uploaded_to', '=', $page->id)
+            ->update(['uploaded_to' => null]);
 
         $page->forceDelete();
 
@@ -267,8 +273,8 @@ class TrashCan
         // exists in the event it has already been destroyed during this request.
         $entity = $deletion->deletable()->first();
         $count = 0;
-        if ($entity) {
-            $count = $this->destroyEntity($deletion->deletable);
+        if ($entity instanceof Entity) {
+            $count = $this->destroyEntity($entity);
         }
         $deletion->delete();
 
@@ -357,31 +363,32 @@ class TrashCan
 
     /**
      * Destroy the given entity.
+     * Returns the number of total entities destroyed in the operation.
      *
      * @throws Exception
      */
     public function destroyEntity(Entity $entity): int
     {
-        if ($entity instanceof Page) {
-            return $this->destroyPage($entity);
-        }
-        if ($entity instanceof Chapter) {
-            return $this->destroyChapter($entity);
-        }
-        if ($entity instanceof Book) {
-            return $this->destroyBook($entity);
-        }
-        if ($entity instanceof Bookshelf) {
-            return $this->destroyShelf($entity);
-        }
+        $result = (new DatabaseTransaction(function () use ($entity) {
+            if ($entity instanceof Page) {
+                return $this->destroyPage($entity);
+            } else if ($entity instanceof Chapter) {
+                return $this->destroyChapter($entity);
+            } else if ($entity instanceof Book) {
+                return $this->destroyBook($entity);
+            } else if ($entity instanceof Bookshelf) {
+                return $this->destroyShelf($entity);
+            }
+            return null;
+        }))->run();
 
-        return 0;
+        return $result ?? 0;
     }
 
     /**
      * Update entity relations to remove or update outstanding connections.
      */
-    protected function destroyCommonRelations(Entity $entity)
+    protected function destroyCommonRelations(Entity $entity): void
     {
         Activity::removeEntity($entity);
         $entity->views()->delete();
@@ -395,10 +402,13 @@ class TrashCan
         $entity->watches()->delete();
         $entity->referencesTo()->delete();
         $entity->referencesFrom()->delete();
+        $entity->slugHistory()->delete();
 
-        if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
+        if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
             $imageService = app()->make(ImageService::class);
-            $imageService->destroy($entity->cover()->first());
+            $imageService->destroy($entity->coverInfo()->getImage());
         }
+
+        $entity->relatedData()->delete();
     }
 }
diff --git a/app/Exceptions/BookStackExceptionHandlerPage.php b/app/Exceptions/BookStackExceptionHandlerPage.php
index 78f586ad0..a53733ac7 100644
--- a/app/Exceptions/BookStackExceptionHandlerPage.php
+++ b/app/Exceptions/BookStackExceptionHandlerPage.php
@@ -2,6 +2,7 @@
 
 namespace BookStack\Exceptions;
 
+use BookStack\App\AppVersion;
 use Illuminate\Contracts\Foundation\ExceptionRenderer;
 
 class BookStackExceptionHandlerPage implements ExceptionRenderer
@@ -30,9 +31,7 @@ class BookStackExceptionHandlerPage implements ExceptionRenderer
         return [
             'PHP Version'       => phpversion(),
             'BookStack Version' => $this->safeReturn(function () {
-                $versionFile = base_path('version');
-
-                return trim(file_get_contents($versionFile));
+                return AppVersion::get();
             }, 'unknown'),
             'Theme Configured' => $this->safeReturn(function () {
                 return config('view.theme');
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index 61e126327..08d326ad8 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -2,7 +2,6 @@
 
 namespace BookStack\Exceptions;
 
-use Exception;
 use Illuminate\Auth\AuthenticationException;
 use Illuminate\Database\Eloquent\ModelNotFoundException;
 use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
@@ -12,6 +11,7 @@ use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 use Illuminate\Validation\ValidationException;
 use Symfony\Component\ErrorHandler\Error\FatalError;
+use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
 use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
 use Throwable;
 
@@ -20,7 +20,7 @@ class Handler extends ExceptionHandler
     /**
      * A list of the exception types that are not reported.
      *
-     * @var array<int, class-string<\Throwable>>
+     * @var array<int, class-string<Throwable>>
      */
     protected $dontReport = [
         NotFoundException::class,
@@ -50,11 +50,11 @@ class Handler extends ExceptionHandler
     /**
      * Report or log an exception.
      *
-     * @param \Throwable $exception
-     *
-     * @throws \Throwable
+     * @param Throwable $exception
      *
      * @return void
+     *@throws Throwable
+     *
      */
     public function report(Throwable $exception)
     {
@@ -64,12 +64,9 @@ class Handler extends ExceptionHandler
     /**
      * Render an exception into an HTTP response.
      *
-     * @param \Illuminate\Http\Request $request
-     * @param Exception                $e
-     *
-     * @return \Illuminate\Http\Response
+     * @param Request $request
      */
-    public function render($request, Throwable $e)
+    public function render($request, Throwable $e): SymfonyResponse
     {
         if ($e instanceof FatalError && str_contains($e->getMessage(), 'bytes exhausted (tried to allocate') && $this->onOutOfMemory) {
             $response = call_user_func($this->onOutOfMemory);
@@ -94,7 +91,7 @@ class Handler extends ExceptionHandler
      * If the callable returns a response, this response will be returned
      * to the request upon error.
      */
-    public function prepareForOutOfMemory(callable $onOutOfMemory)
+    public function prepareForOutOfMemory(callable $onOutOfMemory): void
     {
         $this->onOutOfMemory = $onOutOfMemory;
     }
@@ -102,7 +99,7 @@ class Handler extends ExceptionHandler
     /**
      * Forget the current out of memory handler, if existing.
      */
-    public function forgetOutOfMemoryHandler()
+    public function forgetOutOfMemoryHandler(): void
     {
         $this->onOutOfMemory = null;
     }
@@ -152,12 +149,9 @@ class Handler extends ExceptionHandler
     /**
      * Convert an authentication exception into an unauthenticated response.
      *
-     * @param \Illuminate\Http\Request                 $request
-     * @param \Illuminate\Auth\AuthenticationException $exception
-     *
-     * @return \Illuminate\Http\Response
+     * @param Request $request
      */
-    protected function unauthenticated($request, AuthenticationException $exception)
+    protected function unauthenticated($request, AuthenticationException $exception): SymfonyResponse
     {
         if ($request->expectsJson()) {
             return response()->json(['error' => 'Unauthenticated.'], 401);
@@ -169,12 +163,9 @@ class Handler extends ExceptionHandler
     /**
      * Convert a validation exception into a JSON response.
      *
-     * @param \Illuminate\Http\Request                   $request
-     * @param \Illuminate\Validation\ValidationException $exception
-     *
-     * @return \Illuminate\Http\JsonResponse
+     * @param Request $request
      */
-    protected function invalidJson($request, ValidationException $exception)
+    protected function invalidJson($request, ValidationException $exception): JsonResponse
     {
         return response()->json($exception->errors(), $exception->status);
     }
diff --git a/app/Exports/Controllers/BookExportApiController.php b/app/Exports/Controllers/BookExportApiController.php
index 164946b0c..21f276f8a 100644
--- a/app/Exports/Controllers/BookExportApiController.php
+++ b/app/Exports/Controllers/BookExportApiController.php
@@ -4,7 +4,9 @@ namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\BookQueries;
 use BookStack\Exports\ExportFormatter;
+use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use Throwable;
 
 class BookExportApiController extends ApiController
@@ -13,7 +15,7 @@ class BookExportApiController extends ApiController
         protected ExportFormatter $exportFormatter,
         protected BookQueries $queries,
     ) {
-        $this->middleware('can:content-export');
+        $this->middleware(Permission::ContentExport->middleware());
     }
 
     /**
@@ -63,4 +65,15 @@ class BookExportApiController extends ApiController
 
         return $this->download()->directly($markdown, $book->slug . '.md');
     }
+
+    /**
+     * Export a book as a contained ZIP export file.
+     */
+    public function exportZip(int $id, ZipExportBuilder $builder)
+    {
+        $book = $this->queries->findVisibleByIdOrFail($id);
+        $zip = $builder->buildForBook($book);
+
+        return $this->download()->streamedFileDirectly($zip, $book->slug . '.zip', true);
+    }
 }
diff --git a/app/Exports/Controllers/BookExportController.php b/app/Exports/Controllers/BookExportController.php
index 67247598c..f6bb66666 100644
--- a/app/Exports/Controllers/BookExportController.php
+++ b/app/Exports/Controllers/BookExportController.php
@@ -7,6 +7,7 @@ use BookStack\Exceptions\NotFoundException;
 use BookStack\Exports\ExportFormatter;
 use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use Throwable;
 
 class BookExportController extends Controller
@@ -15,7 +16,7 @@ class BookExportController extends Controller
         protected BookQueries $queries,
         protected ExportFormatter $exportFormatter,
     ) {
-        $this->middleware('can:content-export');
+        $this->middleware(Permission::ContentExport->middleware());
         $this->middleware('throttle:exports');
     }
 
diff --git a/app/Exports/Controllers/ChapterExportApiController.php b/app/Exports/Controllers/ChapterExportApiController.php
index 9914e2b7f..7e5a23c70 100644
--- a/app/Exports/Controllers/ChapterExportApiController.php
+++ b/app/Exports/Controllers/ChapterExportApiController.php
@@ -4,7 +4,9 @@ namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\ChapterQueries;
 use BookStack\Exports\ExportFormatter;
+use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use Throwable;
 
 class ChapterExportApiController extends ApiController
@@ -13,7 +15,7 @@ class ChapterExportApiController extends ApiController
         protected ExportFormatter $exportFormatter,
         protected ChapterQueries $queries,
     ) {
-        $this->middleware('can:content-export');
+        $this->middleware(Permission::ContentExport->middleware());
     }
 
     /**
@@ -63,4 +65,15 @@ class ChapterExportApiController extends ApiController
 
         return $this->download()->directly($markdown, $chapter->slug . '.md');
     }
+
+    /**
+     * Export a chapter as a contained ZIP file.
+     */
+    public function exportZip(int $id, ZipExportBuilder $builder)
+    {
+        $chapter = $this->queries->findVisibleByIdOrFail($id);
+        $zip = $builder->buildForChapter($chapter);
+
+        return $this->download()->streamedFileDirectly($zip, $chapter->slug . '.zip', true);
+    }
 }
diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php
index 849024343..fdb2bba94 100644
--- a/app/Exports/Controllers/ChapterExportController.php
+++ b/app/Exports/Controllers/ChapterExportController.php
@@ -7,6 +7,7 @@ use BookStack\Exceptions\NotFoundException;
 use BookStack\Exports\ExportFormatter;
 use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use Throwable;
 
 class ChapterExportController extends Controller
@@ -15,7 +16,7 @@ class ChapterExportController extends Controller
         protected ChapterQueries $queries,
         protected ExportFormatter $exportFormatter,
     ) {
-        $this->middleware('can:content-export');
+        $this->middleware(Permission::ContentExport->middleware());
         $this->middleware('throttle:exports');
     }
 
diff --git a/app/Exports/Controllers/ImportApiController.php b/app/Exports/Controllers/ImportApiController.php
new file mode 100644
index 000000000..f8eaea5a1
--- /dev/null
+++ b/app/Exports/Controllers/ImportApiController.php
@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BookStack\Exports\Controllers;
+
+use BookStack\Exceptions\ZipImportException;
+use BookStack\Exceptions\ZipValidationException;
+use BookStack\Exports\ImportRepo;
+use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
+use BookStack\Uploads\AttachmentService;
+use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Response;
+
+class ImportApiController extends ApiController
+{
+    public function __construct(
+        protected ImportRepo $imports,
+    ) {
+        $this->middleware(Permission::ContentImport->middleware());
+    }
+
+    /**
+     * List existing ZIP imports visible to the user.
+     * Requires permission to import content.
+     */
+    public function list(): JsonResponse
+    {
+        $query = $this->imports->queryVisible();
+
+        return $this->apiListingResponse($query, [
+            'id', 'name', 'size', 'type', 'created_by', 'created_at', 'updated_at'
+        ]);
+    }
+
+    /**
+     * Start a new import from a ZIP file.
+     * This does not actually run the import since that is performed via the "run" endpoint.
+     * This uploads, validates and stores the ZIP file so it's ready to be imported.
+     *
+     * This "file" parameter must be a BookStack-compatible ZIP file, and this must be
+     * sent via a 'multipart/form-data' type request.
+     *
+     * Requires permission to import content.
+     */
+    public function create(Request $request): JsonResponse
+    {
+        $this->validate($request, $this->rules()['create']);
+
+        $file = $request->file('file');
+
+        try {
+            $import = $this->imports->storeFromUpload($file);
+        } catch (ZipValidationException $exception) {
+            $message = "ZIP upload failed with the following validation errors: \n" . $this->formatErrors($exception->errors);
+            return $this->jsonError($message, 422);
+        }
+
+        return response()->json($import);
+    }
+
+    /**
+     * Read details of a pending ZIP import.
+     * The "details" property contains high-level metadata regarding the ZIP import content,
+     * and the structure of this will change depending on import "type".
+     * Requires permission to import content.
+     */
+    public function read(int $id): JsonResponse
+    {
+        $import = $this->imports->findVisible($id);
+
+        $import->setAttribute('details', $import->decodeMetadata());
+
+        return response()->json($import);
+    }
+
+    /**
+     * Run the import process for an uploaded ZIP import.
+     * The "parent_id" and "parent_type" parameters are required when the import type is "chapter" or "page".
+     * On success, this endpoint returns the imported item.
+     * Requires permission to import content.
+     */
+    public function run(int $id, Request $request): JsonResponse
+    {
+        $import = $this->imports->findVisible($id);
+        $parent = null;
+        $rules = $this->rules()['run'];
+
+        if ($import->type === 'page' || $import->type === 'chapter') {
+            $rules['parent_type'][] = 'required';
+            $rules['parent_id'][] = 'required';
+            $data = $this->validate($request, $rules);
+            $parent = "{$data['parent_type']}:{$data['parent_id']}";
+        }
+
+        try {
+            $entity = $this->imports->runImport($import, $parent);
+        } catch (ZipImportException $exception) {
+            $message = "ZIP import failed with the following errors: \n" . $this->formatErrors($exception->errors);
+            return $this->jsonError($message);
+        }
+
+        return response()->json($entity->withoutRelations());
+    }
+
+    /**
+     * Delete a pending ZIP import from the system.
+     * Requires permission to import content.
+     */
+    public function delete(int $id): Response
+    {
+        $import = $this->imports->findVisible($id);
+        $this->imports->deleteImport($import);
+
+        return response('', 204);
+    }
+
+    protected function rules(): array
+    {
+        return [
+            'create' => [
+                'file' => ['required', ...AttachmentService::getFileValidationRules()],
+            ],
+            'run' => [
+                'parent_type' => ['string', 'in:book,chapter'],
+                'parent_id' => ['int'],
+            ],
+        ];
+    }
+
+    protected function formatErrors(array $errors): string
+    {
+        $parts = [];
+        foreach ($errors as $key => $error) {
+            if (is_string($key)) {
+                $parts[] = "[{$key}] {$error}";
+            } else {
+                $parts[] = $error;
+            }
+        }
+        return implode("\n", $parts);
+    }
+}
diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php
index b938dac8e..7ecc09a41 100644
--- a/app/Exports/Controllers/ImportController.php
+++ b/app/Exports/Controllers/ImportController.php
@@ -8,6 +8,7 @@ use BookStack\Exceptions\ZipImportException;
 use BookStack\Exceptions\ZipValidationException;
 use BookStack\Exports\ImportRepo;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\AttachmentService;
 use Illuminate\Http\Request;
 
@@ -16,7 +17,7 @@ class ImportController extends Controller
     public function __construct(
         protected ImportRepo $imports,
     ) {
-        $this->middleware('can:content-import');
+        $this->middleware(Permission::ContentImport->middleware());
     }
 
     /**
@@ -89,7 +90,7 @@ class ImportController extends Controller
         try {
             $entity = $this->imports->runImport($import, $parent);
         } catch (ZipImportException $exception) {
-            session()->flush();
+            session()->forget(['success', 'warning']);
             $this->showErrorNotification(trans('errors.import_zip_failed_notification'));
             return redirect($import->getUrl())->with('import_errors', $exception->errors);
         }
diff --git a/app/Exports/Controllers/PageExportApiController.php b/app/Exports/Controllers/PageExportApiController.php
index c6e20b615..c5b186289 100644
--- a/app/Exports/Controllers/PageExportApiController.php
+++ b/app/Exports/Controllers/PageExportApiController.php
@@ -4,7 +4,9 @@ namespace BookStack\Exports\Controllers;
 
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Exports\ExportFormatter;
+use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use Throwable;
 
 class PageExportApiController extends ApiController
@@ -13,7 +15,7 @@ class PageExportApiController extends ApiController
         protected ExportFormatter $exportFormatter,
         protected PageQueries $queries,
     ) {
-        $this->middleware('can:content-export');
+        $this->middleware(Permission::ContentExport->middleware());
     }
 
     /**
@@ -63,4 +65,15 @@ class PageExportApiController extends ApiController
 
         return $this->download()->directly($markdown, $page->slug . '.md');
     }
+
+    /**
+     * Export a page as a contained ZIP file.
+     */
+    public function exportZip(int $id, ZipExportBuilder $builder)
+    {
+        $page = $this->queries->findVisibleByIdOrFail($id);
+        $zip = $builder->buildForPage($page);
+
+        return $this->download()->streamedFileDirectly($zip, $page->slug . '.zip', true);
+    }
 }
diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php
index 145dce9dd..9bc79f247 100644
--- a/app/Exports/Controllers/PageExportController.php
+++ b/app/Exports/Controllers/PageExportController.php
@@ -8,6 +8,7 @@ use BookStack\Exceptions\NotFoundException;
 use BookStack\Exports\ExportFormatter;
 use BookStack\Exports\ZipExports\ZipExportBuilder;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use Throwable;
 
 class PageExportController extends Controller
@@ -16,7 +17,7 @@ class PageExportController extends Controller
         protected PageQueries $queries,
         protected ExportFormatter $exportFormatter,
     ) {
-        $this->middleware('can:content-export');
+        $this->middleware(Permission::ContentExport->middleware());
         $this->middleware('throttle:exports');
     }
 
diff --git a/app/Exports/ExportFormatter.php b/app/Exports/ExportFormatter.php
index 85ac7d2c9..ad489aba1 100644
--- a/app/Exports/ExportFormatter.php
+++ b/app/Exports/ExportFormatter.php
@@ -284,7 +284,7 @@ class ExportFormatter
     public function bookToPlainText(Book $book): string
     {
         $bookTree = (new BookContents($book))->getTree(false, true);
-        $text = $book->name . "\n" . $book->description;
+        $text = $book->name . "\n" . $book->descriptionInfo()->getPlain();
         $text = rtrim($text) . "\n\n";
 
         $parts = [];
@@ -318,7 +318,7 @@ class ExportFormatter
     {
         $text = '# ' . $chapter->name . "\n\n";
 
-        $description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
+        $description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert();
         if ($description) {
             $text .= $description . "\n\n";
         }
@@ -338,7 +338,7 @@ class ExportFormatter
         $bookTree = (new BookContents($book))->getTree(false, true);
         $text = '# ' . $book->name . "\n\n";
 
-        $description = (new HtmlToMarkdown($book->descriptionHtml()))->convert();
+        $description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert();
         if ($description) {
             $text .= $description . "\n\n";
         }
diff --git a/app/Exports/Import.php b/app/Exports/Import.php
index 9c1771c46..ca4f52981 100644
--- a/app/Exports/Import.php
+++ b/app/Exports/Import.php
@@ -28,6 +28,8 @@ class Import extends Model implements Loggable
 {
     use HasFactory;
 
+    protected $hidden = ['metadata'];
+
     public function getSizeString(): string
     {
         $mb = round($this->size / 1000000, 2);
diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php
index f72386c47..79db69fca 100644
--- a/app/Exports/ImportRepo.php
+++ b/app/Exports/ImportRepo.php
@@ -16,7 +16,9 @@ use BookStack\Exports\ZipExports\ZipExportReader;
 use BookStack\Exports\ZipExports\ZipExportValidator;
 use BookStack\Exports\ZipExports\ZipImportRunner;
 use BookStack\Facades\Activity;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\FileStorage;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Support\Facades\DB;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -34,21 +36,29 @@ class ImportRepo
      * @return Collection<Import>
      */
     public function getVisibleImports(): Collection
+    {
+        return $this->queryVisible()->get();
+    }
+
+    /**
+     * @return Builder<Import>
+     */
+    public function queryVisible(): Builder
     {
         $query = Import::query();
 
-        if (!userCan('settings-manage')) {
+        if (!userCan(Permission::SettingsManage)) {
             $query->where('created_by', user()->id);
         }
 
-        return $query->get();
+        return $query;
     }
 
     public function findVisible(int $id): Import
     {
         $query = Import::query();
 
-        if (!userCan('settings-manage')) {
+        if (!userCan(Permission::SettingsManage)) {
             $query->where('created_by', user()->id);
         }
 
diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php
index 4f5b2f236..97995738f 100644
--- a/app/Exports/ZipExports/Models/ZipExportAttachment.php
+++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php
@@ -6,7 +6,7 @@ use BookStack\Exports\ZipExports\ZipExportFiles;
 use BookStack\Exports\ZipExports\ZipValidationHelper;
 use BookStack\Uploads\Attachment;
 
-class ZipExportAttachment extends ZipExportModel
+final class ZipExportAttachment extends ZipExportModel
 {
     public ?int $id = null;
     public string $name;
@@ -52,9 +52,9 @@ class ZipExportAttachment extends ZipExportModel
         return $context->validateData($data, $rules);
     }
 
-    public static function fromArray(array $data): self
+    public static function fromArray(array $data): static
     {
-        $model = new self();
+        $model = new static();
 
         $model->id = $data['id'] ?? null;
         $model->name = $data['name'];
diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php
index 39176ded4..ab3fd90ec 100644
--- a/app/Exports/ZipExports/Models/ZipExportBook.php
+++ b/app/Exports/ZipExports/Models/ZipExportBook.php
@@ -8,7 +8,7 @@ use BookStack\Entities\Models\Page;
 use BookStack\Exports\ZipExports\ZipExportFiles;
 use BookStack\Exports\ZipExports\ZipValidationHelper;
 
-class ZipExportBook extends ZipExportModel
+final class ZipExportBook extends ZipExportModel
 {
     public ?int $id = null;
     public string $name;
@@ -55,10 +55,10 @@ class ZipExportBook extends ZipExportModel
         $instance = new self();
         $instance->id = $model->id;
         $instance->name = $model->name;
-        $instance->description_html = $model->descriptionHtml();
+        $instance->description_html = $model->descriptionInfo()->getHtml();
 
-        if ($model->cover) {
-            $instance->cover = $files->referenceForImage($model->cover);
+        if ($model->coverInfo()->exists()) {
+            $instance->cover = $files->referenceForImage($model->coverInfo()->getImage());
         }
 
         $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
@@ -101,9 +101,9 @@ class ZipExportBook extends ZipExportModel
         return $errors;
     }
 
-    public static function fromArray(array $data): self
+    public static function fromArray(array $data): static
     {
-        $model = new self();
+        $model = new static();
 
         $model->id = $data['id'] ?? null;
         $model->name = $data['name'];
diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php
index bf2dc78f8..906ce3d81 100644
--- a/app/Exports/ZipExports/Models/ZipExportChapter.php
+++ b/app/Exports/ZipExports/Models/ZipExportChapter.php
@@ -7,7 +7,7 @@ use BookStack\Entities\Models\Page;
 use BookStack\Exports\ZipExports\ZipExportFiles;
 use BookStack\Exports\ZipExports\ZipValidationHelper;
 
-class ZipExportChapter extends ZipExportModel
+final class ZipExportChapter extends ZipExportModel
 {
     public ?int $id = null;
     public string $name;
@@ -40,7 +40,7 @@ class ZipExportChapter extends ZipExportModel
         $instance = new self();
         $instance->id = $model->id;
         $instance->name = $model->name;
-        $instance->description_html = $model->descriptionHtml();
+        $instance->description_html = $model->descriptionInfo()->getHtml();
         $instance->priority = $model->priority;
         $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
 
@@ -79,9 +79,9 @@ class ZipExportChapter extends ZipExportModel
         return $errors;
     }
 
-    public static function fromArray(array $data): self
+    public static function fromArray(array $data): static
     {
-        $model = new self();
+        $model = new static();
 
         $model->id = $data['id'] ?? null;
         $model->name = $data['name'];
diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php
index e0e7d1198..4c71af0c3 100644
--- a/app/Exports/ZipExports/Models/ZipExportImage.php
+++ b/app/Exports/ZipExports/Models/ZipExportImage.php
@@ -7,7 +7,7 @@ use BookStack\Exports\ZipExports\ZipValidationHelper;
 use BookStack\Uploads\Image;
 use Illuminate\Validation\Rule;
 
-class ZipExportImage extends ZipExportModel
+final class ZipExportImage extends ZipExportModel
 {
     public ?int $id = null;
     public string $name;
@@ -43,9 +43,9 @@ class ZipExportImage extends ZipExportModel
         return $context->validateData($data, $rules);
     }
 
-    public static function fromArray(array $data): self
+    public static function fromArray(array $data): static
     {
-        $model = new self();
+        $model = new static();
 
         $model->id = $data['id'] ?? null;
         $model->name = $data['name'];
diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php
index d3a8c3567..38001c628 100644
--- a/app/Exports/ZipExports/Models/ZipExportModel.php
+++ b/app/Exports/ZipExports/Models/ZipExportModel.php
@@ -30,12 +30,12 @@ abstract class ZipExportModel implements JsonSerializable
     /**
      * Decode the array of data into this export model.
      */
-    abstract public static function fromArray(array $data): self;
+    abstract public static function fromArray(array $data): static;
 
     /**
      * Decode an array of array data into an array of export models.
      * @param array[] $data
-     * @return self[]
+     * @return static[]
      */
     public static function fromManyArray(array $data): array
     {
diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php
index 097443df0..6de7f9446 100644
--- a/app/Exports/ZipExports/Models/ZipExportPage.php
+++ b/app/Exports/ZipExports/Models/ZipExportPage.php
@@ -7,7 +7,7 @@ use BookStack\Entities\Tools\PageContent;
 use BookStack\Exports\ZipExports\ZipExportFiles;
 use BookStack\Exports\ZipExports\ZipValidationHelper;
 
-class ZipExportPage extends ZipExportModel
+final class ZipExportPage extends ZipExportModel
 {
     public ?int $id = null;
     public string $name;
@@ -86,9 +86,9 @@ class ZipExportPage extends ZipExportModel
         return $errors;
     }
 
-    public static function fromArray(array $data): self
+    public static function fromArray(array $data): static
     {
-        $model = new self();
+        $model = new static();
 
         $model->id = $data['id'] ?? null;
         $model->name = $data['name'];
diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php
index 6b4720fca..8ac7f3c4d 100644
--- a/app/Exports/ZipExports/Models/ZipExportTag.php
+++ b/app/Exports/ZipExports/Models/ZipExportTag.php
@@ -5,7 +5,7 @@ namespace BookStack\Exports\ZipExports\Models;
 use BookStack\Activity\Models\Tag;
 use BookStack\Exports\ZipExports\ZipValidationHelper;
 
-class ZipExportTag extends ZipExportModel
+final class ZipExportTag extends ZipExportModel
 {
     public string $name;
     public ?string $value = null;
@@ -39,9 +39,9 @@ class ZipExportTag extends ZipExportModel
         return $context->validateData($data, $rules);
     }
 
-    public static function fromArray(array $data): self
+    public static function fromArray(array $data): static
     {
-        $model = new self();
+        $model = new static();
 
         $model->name = $data['name'];
         $model->value = $data['value'] ?? null;
diff --git a/app/Exports/ZipExports/ZipExportBuilder.php b/app/Exports/ZipExports/ZipExportBuilder.php
index 9a52c9a3c..1a7d88020 100644
--- a/app/Exports/ZipExports/ZipExportBuilder.php
+++ b/app/Exports/ZipExports/ZipExportBuilder.php
@@ -2,6 +2,7 @@
 
 namespace BookStack\Exports\ZipExports;
 
+use BookStack\App\AppVersion;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
@@ -70,12 +71,12 @@ class ZipExportBuilder
         $this->data['exported_at'] = date(DATE_ATOM);
         $this->data['instance'] = [
             'id'      => setting('instance-id', ''),
-            'version' => trim(file_get_contents(base_path('version'))),
+            'version' => AppVersion::get(),
         ];
 
         $zipFile = tempnam(sys_get_temp_dir(), 'bszip-');
         $zip = new ZipArchive();
-        $opened = $zip->open($zipFile, ZipArchive::CREATE);
+        $opened = $zip->open($zipFile, ZipArchive::OVERWRITE);
         if ($opened !== true) {
             throw new ZipExportException('Failed to create zip file for export.');
         }
diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php
index bf5e02133..a79988d44 100644
--- a/app/Exports/ZipExports/ZipExportReferences.php
+++ b/app/Exports/ZipExports/ZipExportReferences.php
@@ -12,26 +12,29 @@ use BookStack\Exports\ZipExports\Models\ZipExportChapter;
 use BookStack\Exports\ZipExports\Models\ZipExportImage;
 use BookStack\Exports\ZipExports\Models\ZipExportModel;
 use BookStack\Exports\ZipExports\Models\ZipExportPage;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\Attachment;
 use BookStack\Uploads\Image;
+use BookStack\Uploads\ImageService;
 
 class ZipExportReferences
 {
-    /** @var ZipExportPage[] */
+    /** @var array<int, ZipExportPage> */
     protected array $pages = [];
-    /** @var ZipExportChapter[] */
+    /** @var array<int, ZipExportChapter> */
     protected array $chapters = [];
-    /** @var ZipExportBook[] */
+    /** @var array<int, ZipExportBook> */
     protected array $books = [];
 
-    /** @var ZipExportAttachment[] */
+    /** @var array<int, ZipExportAttachment> */
     protected array $attachments = [];
 
-    /** @var ZipExportImage[] */
+    /** @var array<int, ZipExportImage> */
     protected array $images = [];
 
     public function __construct(
         protected ZipReferenceParser $parser,
+        protected ImageService $imageService,
     ) {
     }
 
@@ -132,16 +135,25 @@ class ZipExportReferences
                 return "[[bsexport:image:{$model->id}]]";
             }
 
-            // Find and include images if in visibility
+            // Get the page which we'll reference this image upon
             $page = $model->getPage();
-            if ($page && userCan('view', $page)) {
+            $pageExportModel = null;
+            if ($page && isset($this->pages[$page->id])) {
+                $pageExportModel = $this->pages[$page->id];
+            } elseif ($exportModel instanceof ZipExportPage) {
+                $pageExportModel = $exportModel;
+            }
+
+            // Add the image to the export if it's accessible or just return the existing reference if already added
+            if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) {
                 if (!isset($this->images[$model->id])) {
                     $exportImage = ZipExportImage::fromModel($model, $files);
                     $this->images[$model->id] = $exportImage;
-                    $exportModel->images[] = $exportImage;
+                    $pageExportModel->images[] = $exportImage;
                 }
                 return "[[bsexport:image:{$model->id}]]";
             }
+
             return null;
         }
 
diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php
index da0581df6..662dfdf6c 100644
--- a/app/Exports/ZipExports/ZipImportReferences.php
+++ b/app/Exports/ZipExports/ZipImportReferences.php
@@ -29,7 +29,10 @@ class ZipImportReferences
     /** @var Image[] */
     protected array $images = [];
 
-    /** @var array<string, Model> */
+    /**
+     * Mapping keyed by "type:old-reference-id" with values being the new imported equivalent model.
+     * @var array<string, Model>
+     */
     protected array $referenceMap = [];
 
     /** @var array<int, ZipExportPage> */
@@ -108,6 +111,22 @@ class ZipImportReferences
         return null;
     }
 
+    protected function replaceDrawingIdReferences(string $content): string
+    {
+        $referenceRegex = '/\sdrawio-diagram=[\'"](\d+)[\'"]/';
+
+        $result = preg_replace_callback($referenceRegex, function ($matches) {
+            $key = 'image:' . $matches[1];
+            $model = $this->referenceMap[$key] ?? null;
+            if ($model instanceof Image && $model->type === 'drawio') {
+                return ' drawio-diagram="' . $model->id . '"';
+            }
+            return $matches[0];
+        }, $content);
+
+        return $result ?: $content;
+    }
+
     public function replaceReferences(): void
     {
         foreach ($this->books as $book) {
@@ -134,7 +153,9 @@ class ZipImportReferences
             $exportPage = $this->zipExportPageMap[$page->id];
             $contentType = $exportPage->markdown ? 'markdown' : 'html';
             $content = $exportPage->markdown ?: ($exportPage->html ?: '');
+
             $parsed = $this->parser->parseReferences($content, $this->handleReference(...));
+            $parsed = $this->replaceDrawingIdReferences($parsed);
 
             $this->pageRepo->setContentFromInput($page, [
                 $contentType => $parsed,
diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php
index d25a1621f..748acf43f 100644
--- a/app/Exports/ZipExports/ZipImportRunner.php
+++ b/app/Exports/ZipExports/ZipImportRunner.php
@@ -18,6 +18,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportChapter;
 use BookStack\Exports\ZipExports\Models\ZipExportImage;
 use BookStack\Exports\ZipExports\Models\ZipExportPage;
 use BookStack\Exports\ZipExports\Models\ZipExportTag;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\Attachment;
 use BookStack\Uploads\AttachmentService;
 use BookStack\Uploads\FileStorage;
@@ -134,8 +135,8 @@ class ZipImportRunner
             'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
         ]);
 
-        if ($book->cover) {
-            $this->references->addImage($book->cover, null);
+        if ($book->coverInfo()->getImage()) {
+            $this->references->addImage($book->coverInfo()->getImage(), null);
         }
 
         $children = [
@@ -196,8 +197,8 @@ class ZipImportRunner
 
         $this->pageRepo->publishDraft($page, [
             'name' => $exportPage->name,
-            'markdown' => $exportPage->markdown,
-            'html' => $exportPage->html,
+            'markdown' => $exportPage->markdown ?? '',
+            'html' => $exportPage->html ?? '',
             'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
         ]);
 
@@ -288,7 +289,7 @@ class ZipImportRunner
         $attachments = [];
 
         if ($exportModel instanceof ZipExportBook) {
-            if (!userCan('book-create-all')) {
+            if (!userCan(Permission::BookCreateAll)) {
                 $errors[] = trans('errors.import_perms_books');
             }
             array_push($pages, ...$exportModel->pages);
@@ -317,11 +318,11 @@ class ZipImportRunner
 
         if (count($pages) > 0) {
             if ($parent) {
-                if (!userCan('page-create', $parent)) {
+                if (!userCan(Permission::PageCreate, $parent)) {
                     $errors[] = trans('errors.import_perms_pages');
                 }
             } else {
-                $hasPermission = userCan('page-create-all') || userCan('page-create-own');
+                $hasPermission = userCan(Permission::PageCreateAll) || userCan(Permission::PageCreateOwn);
                 if (!$hasPermission) {
                     $errors[] = trans('errors.import_perms_pages');
                 }
@@ -329,13 +330,13 @@ class ZipImportRunner
         }
 
         if (count($images) > 0) {
-            if (!userCan('image-create-all')) {
+            if (!userCan(Permission::ImageCreateAll)) {
                 $errors[] = trans('errors.import_perms_images');
             }
         }
 
         if (count($attachments) > 0) {
-            if (!userCan('attachment-create-all')) {
+            if (!userCan(Permission::AttachmentCreateAll)) {
                 $errors[] = trans('errors.import_perms_attachments');
             }
         }
diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php
index c0dbe2fca..8c0f206d0 100644
--- a/app/Http/ApiController.php
+++ b/app/Http/ApiController.php
@@ -8,7 +8,13 @@ use Illuminate\Http\JsonResponse;
 
 abstract class ApiController extends Controller
 {
-    protected $rules = [];
+    /**
+     * The validation rules for this controller.
+     * Can alternative be defined in a rules() method is they need to be dynamic.
+     *
+     * @var array<string, array<string, string[]>>
+     */
+    protected array $rules = [];
 
     /**
      * Provide a paginated listing JSON response in a standard format
diff --git a/app/Http/Controller.php b/app/Http/Controller.php
index 090cf523a..5d3be4951 100644
--- a/app/Http/Controller.php
+++ b/app/Http/Controller.php
@@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable;
 use BookStack\App\Model;
 use BookStack\Exceptions\NotifyException;
 use BookStack\Facades\Activity;
+use BookStack\Permissions\Permission;
 use Illuminate\Foundation\Bus\DispatchesJobs;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Http\JsonResponse;
@@ -27,10 +28,9 @@ abstract class Controller extends BaseController
     }
 
     /**
-     * Stops the application and shows a permission error if
-     * the application is in demo mode.
+     * Stops the application and shows a permission error if the application is in demo mode.
      */
-    protected function preventAccessInDemoMode()
+    protected function preventAccessInDemoMode(): void
     {
         if (config('app.env') === 'demo') {
             $this->showPermissionError();
@@ -40,28 +40,27 @@ abstract class Controller extends BaseController
     /**
      * Adds the page title into the view.
      */
-    public function setPageTitle(string $title)
+    public function setPageTitle(string $title): void
     {
         view()->share('pageTitle', $title);
     }
 
     /**
-     * On a permission error redirect to home and display.
-     * the error as a notification.
+     * On a permission error redirect to home and display the error as a notification.
      *
-     * @return never
+     * @throws NotifyException
      */
-    protected function showPermissionError()
+    protected function showPermissionError(string $redirectLocation = '/'): never
     {
         $message = request()->wantsJson() ? trans('errors.permissionJson') : trans('errors.permission');
 
-        throw new NotifyException($message, '/', 403);
+        throw new NotifyException($message, $redirectLocation, 403);
     }
 
     /**
      * Checks that the current user has the given permission otherwise throw an exception.
      */
-    protected function checkPermission(string $permission): void
+    protected function checkPermission(string|Permission $permission): void
     {
         if (!user() || !user()->can($permission)) {
             $this->showPermissionError();
@@ -81,10 +80,10 @@ abstract class Controller extends BaseController
     /**
      * Check the current user's permissions against an ownable item otherwise throw an exception.
      */
-    protected function checkOwnablePermission(string $permission, Model $ownable): void
+    protected function checkOwnablePermission(string|Permission $permission, Model $ownable, string $redirectLocation = '/'): void
     {
         if (!userCan($permission, $ownable)) {
-            $this->showPermissionError();
+            $this->showPermissionError($redirectLocation);
         }
     }
 
@@ -92,7 +91,7 @@ abstract class Controller extends BaseController
      * Check if a user has a permission or bypass the permission
      * check if the given callback resolves true.
      */
-    protected function checkPermissionOr(string $permission, callable $callback): void
+    protected function checkPermissionOr(string|Permission $permission, callable $callback): void
     {
         if ($callback() !== true) {
             $this->checkPermission($permission);
@@ -103,7 +102,7 @@ abstract class Controller extends BaseController
      * Check if the current user has a permission or bypass if the provided user
      * id matches the current user.
      */
-    protected function checkPermissionOrCurrentUser(string $permission, int $userId): void
+    protected function checkPermissionOrCurrentUser(string|Permission $permission, int $userId): void
     {
         $this->checkPermissionOr($permission, function () use ($userId) {
             return $userId === user()->id;
@@ -111,7 +110,7 @@ abstract class Controller extends BaseController
     }
 
     /**
-     * Send back a json error message.
+     * Send back a JSON error message.
      */
     protected function jsonError(string $messageText = '', int $statusCode = 500): JsonResponse
     {
@@ -127,7 +126,7 @@ abstract class Controller extends BaseController
     }
 
     /**
-     * Show a positive, successful notification to the user on next view load.
+     * Show a positive, successful notification to the user on the next view load.
      */
     protected function showSuccessNotification(string $message): void
     {
@@ -135,7 +134,7 @@ abstract class Controller extends BaseController
     }
 
     /**
-     * Show a warning notification to the user on next view load.
+     * Show a warning notification to the user on the next view load.
      */
     protected function showWarningNotification(string $message): void
     {
@@ -143,7 +142,7 @@ abstract class Controller extends BaseController
     }
 
     /**
-     * Show an error notification to the user on next view load.
+     * Show an error notification to the user on the next view load.
      */
     protected function showErrorNotification(string $message): void
     {
@@ -163,7 +162,7 @@ abstract class Controller extends BaseController
      */
     protected function getImageValidationRules(): array
     {
-        return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
+        return ['image_extension', 'mimes:jpeg,png,gif,webp,avif', 'max:' . (config('app.upload_limit') * 1000)];
     }
 
     /**
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 30714e2ac..00bf8cbe1 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -9,6 +9,8 @@ class Kernel extends HttpKernel
     /**
      * The application's global HTTP middleware stack.
      * These middleware are run during every request to your application.
+     *
+     * @var list<class-string>
      */
     protected $middleware = [
         \BookStack\Http\Middleware\PreventRequestsDuringMaintenance::class,
@@ -21,7 +23,7 @@ class Kernel extends HttpKernel
     /**
      * The application's route middleware groups.
      *
-     * @var array
+     * @var array<string, array<int, class-string>>
      */
     protected $middlewareGroups = [
         'web' => [
@@ -47,7 +49,7 @@ class Kernel extends HttpKernel
     /**
      * The application's middleware aliases.
      *
-     * @var array
+     * @var array<string, class-string>
      */
     protected $middlewareAliases = [
         'auth'       => \BookStack\Http\Middleware\Authenticate::class,
diff --git a/app/Http/Middleware/ApiAuthenticate.php b/app/Http/Middleware/ApiAuthenticate.php
index 5f3ad3168..15b5a325a 100644
--- a/app/Http/Middleware/ApiAuthenticate.php
+++ b/app/Http/Middleware/ApiAuthenticate.php
@@ -3,6 +3,7 @@
 namespace BookStack\Http\Middleware;
 
 use BookStack\Exceptions\ApiAuthException;
+use BookStack\Permissions\Permission;
 use Closure;
 use Illuminate\Http\Request;
 
@@ -51,7 +52,7 @@ class ApiAuthenticate
      */
     protected function sessionUserHasApiAccess(): bool
     {
-        $hasApiPermission = user()->can('access-api');
+        $hasApiPermission = user()->can(Permission::AccessApi);
 
         return $hasApiPermission && user()->hasAppAccess();
     }
diff --git a/app/Http/Middleware/CheckUserHasPermission.php b/app/Http/Middleware/CheckUserHasPermission.php
index b5678e734..ea4ff652a 100644
--- a/app/Http/Middleware/CheckUserHasPermission.php
+++ b/app/Http/Middleware/CheckUserHasPermission.php
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Middleware;
 
+use BookStack\Permissions\Permission;
 use Closure;
 use Illuminate\Http\Request;
 
@@ -10,13 +11,9 @@ class CheckUserHasPermission
     /**
      * Handle an incoming request.
      *
-     * @param \Illuminate\Http\Request $request
-     * @param \Closure                 $next
-     * @param string                   $permission
-     *
      * @return mixed
      */
-    public function handle($request, Closure $next, $permission)
+    public function handle(Request $request, Closure $next, string|Permission $permission)
     {
         if (!user()->can($permission)) {
             return $this->errorResponse($request);
diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php
index 484c15ae8..2d7d5d9d4 100644
--- a/app/Http/Middleware/EncryptCookies.php
+++ b/app/Http/Middleware/EncryptCookies.php
@@ -9,7 +9,7 @@ class EncryptCookies extends Middleware
     /**
      * The names of the cookies that should not be encrypted.
      *
-     * @var array
+     * @var array<int, string>
      */
     protected $except = [
         //
diff --git a/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/app/Http/Middleware/PreventRequestsDuringMaintenance.php
index dfb9592e1..c1a02c8cc 100644
--- a/app/Http/Middleware/PreventRequestsDuringMaintenance.php
+++ b/app/Http/Middleware/PreventRequestsDuringMaintenance.php
@@ -9,7 +9,7 @@ class PreventRequestsDuringMaintenance extends Middleware
     /**
      * The URIs that should be reachable while maintenance mode is enabled.
      *
-     * @var array
+     * @var array<int, string>
      */
     protected $except = [
         //
diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php
index cbdc88fb9..ec5725d67 100644
--- a/app/Http/Middleware/TrimStrings.php
+++ b/app/Http/Middleware/TrimStrings.php
@@ -9,7 +9,7 @@ class TrimStrings extends Middleware
     /**
      * The names of the attributes that should not be trimmed.
      *
-     * @var array
+     * @var array<int, string>
      */
     protected $except = [
         'password',
diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php
index 0fe0247b8..ecf6cb18a 100644
--- a/app/Http/Middleware/TrustProxies.php
+++ b/app/Http/Middleware/TrustProxies.php
@@ -11,7 +11,7 @@ class TrustProxies extends Middleware
     /**
      * The trusted proxies for this application.
      *
-     * @var array
+     * @var array<int,string>|string|null
      */
     protected $proxies;
 
diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php
index 804a22bc0..7c8689b8b 100644
--- a/app/Http/Middleware/VerifyCsrfToken.php
+++ b/app/Http/Middleware/VerifyCsrfToken.php
@@ -16,7 +16,7 @@ class VerifyCsrfToken extends Middleware
     /**
      * The URIs that should be excluded from CSRF verification.
      *
-     * @var array
+     * @var array<int, string>
      */
     protected $except = [
         'saml2/*',
diff --git a/app/Permissions/ContentPermissionApiController.php b/app/Permissions/ContentPermissionApiController.php
index 23b75db35..0720cc478 100644
--- a/app/Permissions/ContentPermissionApiController.php
+++ b/app/Permissions/ContentPermissionApiController.php
@@ -16,7 +16,7 @@ class ContentPermissionApiController extends ApiController
     ) {
     }
 
-    protected $rules = [
+    protected array $rules = [
         'update' => [
             'owner_id'  => ['int'],
 
@@ -51,7 +51,7 @@ class ContentPermissionApiController extends ApiController
         $entity = $this->entities->get($contentType)
             ->newQuery()->scopes(['visible'])->findOrFail($contentId);
 
-        $this->checkOwnablePermission('restrictions-manage', $entity);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $entity);
 
         return response()->json($this->formattedPermissionDataForEntity($entity));
     }
@@ -71,7 +71,7 @@ class ContentPermissionApiController extends ApiController
         $entity = $this->entities->get($contentType)
             ->newQuery()->scopes(['visible'])->findOrFail($contentId);
 
-        $this->checkOwnablePermission('restrictions-manage', $entity);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $entity);
 
         $data = $this->validate($request, $this->rules()['update']);
         $this->permissionsUpdater->updateFromApiRequestData($entity, $data);
diff --git a/app/Permissions/EntityPermissionEvaluator.php b/app/Permissions/EntityPermissionEvaluator.php
index 98ec03306..30c97b700 100644
--- a/app/Permissions/EntityPermissionEvaluator.php
+++ b/app/Permissions/EntityPermissionEvaluator.php
@@ -30,7 +30,7 @@ class EntityPermissionEvaluator
     }
 
     /**
-     * @param array<string, array<string, int>> $permitsByType
+     * @param array<string, array<int, string>> $permitsByType
      */
     protected function evaluatePermitsByType(array $permitsByType): ?int
     {
@@ -50,7 +50,7 @@ class EntityPermissionEvaluator
     /**
      * @param string[] $typeIdChain
      * @param array<string, EntityPermission[]> $permissionMapByTypeId
-     * @return array<string, array<string, int>>
+     * @return array<string, array<int, string>>
      */
     protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array
     {
diff --git a/app/Permissions/JointPermissionBuilder.php b/app/Permissions/JointPermissionBuilder.php
index c2922cdc9..56b22ad16 100644
--- a/app/Permissions/JointPermissionBuilder.php
+++ b/app/Permissions/JointPermissionBuilder.php
@@ -29,7 +29,7 @@ class JointPermissionBuilder
     /**
      * Re-generate all entity permission from scratch.
      */
-    public function rebuildForAll()
+    public function rebuildForAll(): void
     {
         JointPermission::query()->truncate();
 
@@ -51,7 +51,7 @@ class JointPermissionBuilder
     /**
      * Rebuild the entity jointPermissions for a particular entity.
      */
-    public function rebuildForEntity(Entity $entity)
+    public function rebuildForEntity(Entity $entity): void
     {
         $entities = [$entity];
         if ($entity instanceof Book) {
@@ -119,7 +119,7 @@ class JointPermissionBuilder
     /**
      * Build joint permissions for the given book and role combinations.
      */
-    protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
+    protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false): void
     {
         $entities = clone $books;
 
@@ -143,7 +143,7 @@ class JointPermissionBuilder
     /**
      * Rebuild the entity jointPermissions for a collection of entities.
      */
-    protected function buildJointPermissionsForEntities(array $entities)
+    protected function buildJointPermissionsForEntities(array $entities): void
     {
         $roles = Role::query()->get()->values()->all();
         $this->deleteManyJointPermissionsForEntities($entities);
@@ -155,21 +155,19 @@ class JointPermissionBuilder
      *
      * @param Entity[] $entities
      */
-    protected function deleteManyJointPermissionsForEntities(array $entities)
+    protected function deleteManyJointPermissionsForEntities(array $entities): void
     {
         $simpleEntities = $this->entitiesToSimpleEntities($entities);
         $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
 
-        DB::transaction(function () use ($idsByType) {
-            foreach ($idsByType as $type => $ids) {
-                foreach (array_chunk($ids, 1000) as $idChunk) {
-                    DB::table('joint_permissions')
-                        ->where('entity_type', '=', $type)
-                        ->whereIn('entity_id', $idChunk)
-                        ->delete();
-                }
+        foreach ($idsByType as $type => $ids) {
+            foreach (array_chunk($ids, 1000) as $idChunk) {
+                DB::table('joint_permissions')
+                    ->where('entity_type', '=', $type)
+                    ->whereIn('entity_id', $idChunk)
+                    ->delete();
             }
-        });
+        }
     }
 
     /**
@@ -195,7 +193,7 @@ class JointPermissionBuilder
      * @param Entity[] $originalEntities
      * @param Role[]   $roles
      */
-    protected function createManyJointPermissions(array $originalEntities, array $roles)
+    protected function createManyJointPermissions(array $originalEntities, array $roles): void
     {
         $entities = $this->entitiesToSimpleEntities($originalEntities);
         $jointPermissions = [];
@@ -225,11 +223,9 @@ class JointPermissionBuilder
             }
         }
 
-        DB::transaction(function () use ($jointPermissions) {
-            foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
-                DB::table('joint_permissions')->insert($jointPermissionChunk);
-            }
-        });
+        foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
+            DB::table('joint_permissions')->insert($jointPermissionChunk);
+        }
     }
 
     /**
diff --git a/app/Permissions/Models/EntityPermission.php b/app/Permissions/Models/EntityPermission.php
index 1ef9c2886..efad2ba39 100644
--- a/app/Permissions/Models/EntityPermission.php
+++ b/app/Permissions/Models/EntityPermission.php
@@ -18,8 +18,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
  */
 class EntityPermission extends Model
 {
-    public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
-
     protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
     public $timestamps = false;
     protected $hidden = ['entity_id', 'entity_type', 'id'];
diff --git a/app/Permissions/Models/RolePermission.php b/app/Permissions/Models/RolePermission.php
index d43313ea0..67b1c55f5 100644
--- a/app/Permissions/Models/RolePermission.php
+++ b/app/Permissions/Models/RolePermission.php
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 /**
  * @property int $id
  * @property string $name
- * @property string $display_name
  */
 class RolePermission extends Model
 {
diff --git a/app/Permissions/Permission.php b/app/Permissions/Permission.php
new file mode 100644
index 000000000..04878ada0
--- /dev/null
+++ b/app/Permissions/Permission.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace BookStack\Permissions;
+
+/**
+ * Enum to represent the permissions which may be used in checks.
+ * These generally align with RolePermission names, although some are abstract or truncated as some checks
+ * are performed across a range of different items which may be subject to inheritance and other complications.
+ *
+ * We use and still allow the string values in usage to allow for compatibility with scenarios where
+ * users have customised their instance with additional permissions via the theme system.
+ * This enum primarily exists for alignment within the codebase.
+ *
+ * Permissions with all/own suffixes may also be represented as a higher-level alias without the own/all
+ * suffix, which are used and assessed in the permission system logic.
+ */
+enum Permission: string
+{
+    // Generic Actions
+    // Used for more abstract entity permission checks
+    case View = 'view';
+    case Create = 'create';
+    case Update = 'update';
+    case Delete = 'delete';
+
+    // System Permissions
+    case AccessApi = 'access-api';
+    case ContentExport = 'content-export';
+    case ContentImport = 'content-import';
+    case EditorChange = 'editor-change';
+    case ReceiveNotifications = 'receive-notifications';
+    case RestrictionsManage = 'restrictions-manage';
+    case RestrictionsManageAll = 'restrictions-manage-all';
+    case RestrictionsManageOwn = 'restrictions-manage-own';
+    case SettingsManage = 'settings-manage';
+    case TemplatesManage = 'templates-manage';
+    case UserRolesManage = 'user-roles-manage';
+    case UsersManage = 'users-manage';
+
+    // Non-entity content permissions
+    case AttachmentCreate = 'attachment-create';
+    case AttachmentCreateAll = 'attachment-create-all';
+    case AttachmentCreateOwn = 'attachment-create-own';
+    case AttachmentDelete = 'attachment-delete';
+    case AttachmentDeleteAll = 'attachment-delete-all';
+    case AttachmentDeleteOwn = 'attachment-delete-own';
+    case AttachmentUpdate = 'attachment-update';
+    case AttachmentUpdateAll = 'attachment-update-all';
+    case AttachmentUpdateOwn = 'attachment-update-own';
+
+    case CommentCreateAll = 'comment-create-all';
+    case CommentDelete = 'comment-delete';
+    case CommentDeleteAll = 'comment-delete-all';
+    case CommentDeleteOwn = 'comment-delete-own';
+    case CommentUpdate = 'comment-update';
+    case CommentUpdateAll = 'comment-update-all';
+    case CommentUpdateOwn = 'comment-update-own';
+
+    case ImageCreateAll = 'image-create-all';
+    case ImageCreateOwn = 'image-create-own';
+    case ImageDelete = 'image-delete';
+    case ImageDeleteAll = 'image-delete-all';
+    case ImageDeleteOwn = 'image-delete-own';
+    case ImageUpdate = 'image-update';
+    case ImageUpdateAll = 'image-update-all';
+    case ImageUpdateOwn = 'image-update-own';
+
+    // Entity content permissions
+    case BookCreate = 'book-create';
+    case BookCreateAll = 'book-create-all';
+    case BookCreateOwn = 'book-create-own';
+    case BookDelete = 'book-delete';
+    case BookDeleteAll = 'book-delete-all';
+    case BookDeleteOwn = 'book-delete-own';
+    case BookUpdate = 'book-update';
+    case BookUpdateAll = 'book-update-all';
+    case BookUpdateOwn = 'book-update-own';
+    case BookView = 'book-view';
+    case BookViewAll = 'book-view-all';
+    case BookViewOwn = 'book-view-own';
+
+    case BookshelfCreate = 'bookshelf-create';
+    case BookshelfCreateAll = 'bookshelf-create-all';
+    case BookshelfCreateOwn = 'bookshelf-create-own';
+    case BookshelfDelete = 'bookshelf-delete';
+    case BookshelfDeleteAll = 'bookshelf-delete-all';
+    case BookshelfDeleteOwn = 'bookshelf-delete-own';
+    case BookshelfUpdate = 'bookshelf-update';
+    case BookshelfUpdateAll = 'bookshelf-update-all';
+    case BookshelfUpdateOwn = 'bookshelf-update-own';
+    case BookshelfView = 'bookshelf-view';
+    case BookshelfViewAll = 'bookshelf-view-all';
+    case BookshelfViewOwn = 'bookshelf-view-own';
+
+    case ChapterCreate = 'chapter-create';
+    case ChapterCreateAll = 'chapter-create-all';
+    case ChapterCreateOwn = 'chapter-create-own';
+    case ChapterDelete = 'chapter-delete';
+    case ChapterDeleteAll = 'chapter-delete-all';
+    case ChapterDeleteOwn = 'chapter-delete-own';
+    case ChapterUpdate = 'chapter-update';
+    case ChapterUpdateAll = 'chapter-update-all';
+    case ChapterUpdateOwn = 'chapter-update-own';
+    case ChapterView = 'chapter-view';
+    case ChapterViewAll = 'chapter-view-all';
+    case ChapterViewOwn = 'chapter-view-own';
+
+    case PageCreate = 'page-create';
+    case PageCreateAll = 'page-create-all';
+    case PageCreateOwn = 'page-create-own';
+    case PageDelete = 'page-delete';
+    case PageDeleteAll = 'page-delete-all';
+    case PageDeleteOwn = 'page-delete-own';
+    case PageUpdate = 'page-update';
+    case PageUpdateAll = 'page-update-all';
+    case PageUpdateOwn = 'page-update-own';
+    case PageView = 'page-view';
+    case PageViewAll = 'page-view-all';
+    case PageViewOwn = 'page-view-own';
+
+    /**
+     * Get the generic permissions which may be queried for entities.
+     */
+    public static function genericForEntity(): array
+    {
+        return [
+            self::View,
+            self::Create,
+            self::Update,
+            self::Delete,
+        ];
+    }
+
+    /**
+     * Return the application permission-check middleware-string for this permission.
+     * Uses registered CheckUserHasPermission middleware.
+     */
+    public function middleware(): string
+    {
+        return 'can:' . $this->value;
+    }
+}
diff --git a/app/Permissions/PermissionApplicator.php b/app/Permissions/PermissionApplicator.php
index ce4a543fd..c44a18a4d 100644
--- a/app/Permissions/PermissionApplicator.php
+++ b/app/Permissions/PermissionApplicator.php
@@ -7,8 +7,7 @@ use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use BookStack\Permissions\Models\EntityPermission;
-use BookStack\Users\Models\HasCreatorAndUpdater;
-use BookStack\Users\Models\HasOwner;
+use BookStack\Users\Models\OwnableInterface;
 use BookStack\Users\Models\User;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Query\Builder as QueryBuilder;
@@ -24,14 +23,13 @@ class PermissionApplicator
 
     /**
      * Checks if an entity has a restriction set upon it.
-     *
-     * @param Model&(HasCreatorAndUpdater|HasOwner) $ownable
      */
-    public function checkOwnableUserAccess(Model $ownable, string $permission): bool
+    public function checkOwnableUserAccess(Model&OwnableInterface $ownable, string|Permission $permission): bool
     {
-        $explodedPermission = explode('-', $permission);
+        $permissionName = is_string($permission) ? $permission : $permission->value;
+        $explodedPermission = explode('-', $permissionName);
         $action = $explodedPermission[1] ?? $explodedPermission[0];
-        $fullPermission = count($explodedPermission) > 1 ? $permission : $ownable->getMorphClass() . '-' . $permission;
+        $fullPermission = count($explodedPermission) > 1 ? $permissionName : $ownable->getMorphClass() . '-' . $permissionName;
 
         $user = $this->currentUser();
         $userRoleIds = $this->getCurrentUserRoleIds();
@@ -39,21 +37,21 @@ class PermissionApplicator
         $allRolePermission = $user->can($fullPermission . '-all');
         $ownRolePermission = $user->can($fullPermission . '-own');
         $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
-        $ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
+        $ownerField = $ownable->getOwnerFieldName();
         $ownableFieldVal = $ownable->getAttribute($ownerField);
 
-        if (is_null($ownableFieldVal)) {
-            throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
-        }
-
         $isOwner = $user->id === $ownableFieldVal;
         $hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
 
-        // Handle non entity specific jointPermissions
+        // Handle non-entity-specific jointPermissions
         if (in_array($explodedPermission[0], $nonJointPermissions)) {
             return $hasRolePermission;
         }
 
+        if (!($ownable instanceof Entity)) {
+            return false;
+        }
+
         $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
 
         return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
@@ -74,12 +72,13 @@ class PermissionApplicator
      * Checks if a user has the given permission for any items in the system.
      * Can be passed an entity instance to filter on a specific type.
      */
-    public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
+    public function checkUserHasEntityPermissionOnAny(string|Permission $action, string $entityClass = ''): bool
     {
-        $this->ensureValidEntityAction($action);
+        $permissionName = is_string($action) ? $action : $action->value;
+        $this->ensureValidEntityAction($permissionName);
 
         $permissionQuery = EntityPermission::query()
-            ->where($action, '=', true)
+            ->where($permissionName, '=', true)
             ->whereIn('role_id', $this->getCurrentUserRoleIds());
 
         if (!empty($entityClass)) {
@@ -141,10 +140,10 @@ class PermissionApplicator
                 /** @var Builder $query */
                 $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
                 ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
-                    $query->select('id')->from('pages')
-                        ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                    $query->select('page_id')->from('entity_page_data')
+                        ->whereColumn('entity_page_data.page_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
                         ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
-                        ->where('pages.draft', '=', false);
+                        ->where('entity_page_data.draft', '=', false);
                 });
             });
     }
@@ -194,18 +193,18 @@ class PermissionApplicator
     {
         $fullPageIdColumn = $tableName . '.' . $pageIdColumn;
         return $this->restrictEntityQuery($query)
-            ->where(function ($query) use ($fullPageIdColumn) {
-                /** @var Builder $query */
-                $query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
-                    $query->select('id')->from('pages')
-                        ->whereColumn('pages.id', '=', $fullPageIdColumn)
-                        ->where('pages.draft', '=', false);
-                })->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
-                    $query->select('id')->from('pages')
-                        ->whereColumn('pages.id', '=', $fullPageIdColumn)
-                        ->where('pages.draft', '=', true)
-                        ->where('pages.created_by', '=', $this->currentUser()->id);
-                });
+            ->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
+                $query->select('id')->from('entities')
+                    ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id')
+                    ->whereColumn('entities.id', '=', $fullPageIdColumn)
+                    ->where('entities.type', '=', 'page')
+                    ->where(function (QueryBuilder $query) {
+                        $query->where('entity_page_data.draft', '=', false)
+                            ->orWhere(function (QueryBuilder $query) {
+                                $query->where('entity_page_data.draft', '=', true)
+                                    ->where('entities.created_by', '=', $this->currentUser()->id);
+                            });
+                    });
             });
     }
 
@@ -234,8 +233,13 @@ class PermissionApplicator
      */
     protected function ensureValidEntityAction(string $action): void
     {
-        if (!in_array($action, EntityPermission::PERMISSIONS)) {
-            throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
+        $allowed = Permission::genericForEntity();
+        foreach ($allowed as $permission) {
+            if ($permission->value === $action) {
+                return;
+            }
         }
+
+        throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
     }
 }
diff --git a/app/Permissions/PermissionsController.php b/app/Permissions/PermissionsController.php
index 5d2035870..d93d9e718 100644
--- a/app/Permissions/PermissionsController.php
+++ b/app/Permissions/PermissionsController.php
@@ -7,6 +7,7 @@ use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Http\Controller;
 use BookStack\Permissions\Models\EntityPermission;
 use BookStack\Users\Models\Role;
+use BookStack\Util\DatabaseTransaction;
 use Illuminate\Http\Request;
 
 class PermissionsController extends Controller
@@ -23,7 +24,7 @@ class PermissionsController extends Controller
     public function showForPage(string $bookSlug, string $pageSlug)
     {
         $page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('restrictions-manage', $page);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $page);
 
         $this->setPageTitle(trans('entities.pages_permissions'));
         return view('pages.permissions', [
@@ -38,9 +39,11 @@ class PermissionsController extends Controller
     public function updateForPage(Request $request, string $bookSlug, string $pageSlug)
     {
         $page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
-        $this->checkOwnablePermission('restrictions-manage', $page);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $page);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
+        (new DatabaseTransaction(function () use ($page, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.pages_permissions_success'));
 
@@ -53,7 +56,7 @@ class PermissionsController extends Controller
     public function showForChapter(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('restrictions-manage', $chapter);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $chapter);
 
         $this->setPageTitle(trans('entities.chapters_permissions'));
         return view('chapters.permissions', [
@@ -68,9 +71,11 @@ class PermissionsController extends Controller
     public function updateForChapter(Request $request, string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
-        $this->checkOwnablePermission('restrictions-manage', $chapter);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $chapter);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
+        (new DatabaseTransaction(function () use ($chapter, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.chapters_permissions_success'));
 
@@ -83,7 +88,7 @@ class PermissionsController extends Controller
     public function showForBook(string $slug)
     {
         $book = $this->queries->books->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('restrictions-manage', $book);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $book);
 
         $this->setPageTitle(trans('entities.books_permissions'));
         return view('books.permissions', [
@@ -98,9 +103,11 @@ class PermissionsController extends Controller
     public function updateForBook(Request $request, string $slug)
     {
         $book = $this->queries->books->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('restrictions-manage', $book);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $book);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
+        (new DatabaseTransaction(function () use ($book, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.books_permissions_updated'));
 
@@ -113,7 +120,7 @@ class PermissionsController extends Controller
     public function showForShelf(string $slug)
     {
         $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('restrictions-manage', $shelf);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $shelf);
 
         $this->setPageTitle(trans('entities.shelves_permissions'));
         return view('shelves.permissions', [
@@ -128,9 +135,11 @@ class PermissionsController extends Controller
     public function updateForShelf(Request $request, string $slug)
     {
         $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('restrictions-manage', $shelf);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $shelf);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
+        (new DatabaseTransaction(function () use ($shelf, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
 
@@ -143,9 +152,12 @@ class PermissionsController extends Controller
     public function copyShelfPermissionsToBooks(string $slug)
     {
         $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
-        $this->checkOwnablePermission('restrictions-manage', $shelf);
+        $this->checkOwnablePermission(Permission::RestrictionsManage, $shelf);
+
+        $updateCount = (new DatabaseTransaction(function () use ($shelf) {
+            return $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
+        }))->run();
 
-        $updateCount = $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
         $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
 
         return redirect($shelf->getUrl());
@@ -156,7 +168,7 @@ class PermissionsController extends Controller
      */
     public function formRowForRole(string $entityType, string $roleId)
     {
-        $this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own'));
+        $this->checkPermissionOr(Permission::RestrictionsManageAll, fn() => userCan(Permission::RestrictionsManageOwn));
 
         $role = Role::query()->findOrFail($roleId);
 
diff --git a/app/Permissions/PermissionsRepo.php b/app/Permissions/PermissionsRepo.php
index b41612968..6ced7b751 100644
--- a/app/Permissions/PermissionsRepo.php
+++ b/app/Permissions/PermissionsRepo.php
@@ -7,6 +7,7 @@ use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
 use BookStack\Permissions\Models\RolePermission;
 use BookStack\Users\Models\Role;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 use Illuminate\Database\Eloquent\Collection;
 
@@ -48,38 +49,42 @@ class PermissionsRepo
      */
     public function saveNewRole(array $roleData): Role
     {
-        $role = new Role($roleData);
-        $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
-        $role->save();
+        return (new DatabaseTransaction(function () use ($roleData) {
+            $role = new Role($roleData);
+            $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
+            $role->save();
 
-        $permissions = $roleData['permissions'] ?? [];
-        $this->assignRolePermissions($role, $permissions);
-        $this->permissionBuilder->rebuildForRole($role);
+            $permissions = $roleData['permissions'] ?? [];
+            $this->assignRolePermissions($role, $permissions);
+            $this->permissionBuilder->rebuildForRole($role);
 
-        Activity::add(ActivityType::ROLE_CREATE, $role);
+            Activity::add(ActivityType::ROLE_CREATE, $role);
 
-        return $role;
+            return $role;
+        }))->run();
     }
 
     /**
      * Updates an existing role.
-     * Ensures Admin system role always have core permissions.
+     * Ensures the Admin system role always has core permissions.
      */
     public function updateRole($roleId, array $roleData): Role
     {
         $role = $this->getRoleById($roleId);
 
-        if (isset($roleData['permissions'])) {
-            $this->assignRolePermissions($role, $roleData['permissions']);
-        }
+        return (new DatabaseTransaction(function () use ($role, $roleData) {
+            if (isset($roleData['permissions'])) {
+                $this->assignRolePermissions($role, $roleData['permissions']);
+            }
 
-        $role->fill($roleData);
-        $role->save();
-        $this->permissionBuilder->rebuildForRole($role);
+            $role->fill($roleData);
+            $role->save();
+            $this->permissionBuilder->rebuildForRole($role);
 
-        Activity::add(ActivityType::ROLE_UPDATE, $role);
+            Activity::add(ActivityType::ROLE_UPDATE, $role);
 
-        return $role;
+            return $role;
+        }))->run();
     }
 
     /**
@@ -114,7 +119,7 @@ class PermissionsRepo
     /**
      * Delete a role from the system.
      * Check it's not an admin role or set as default before deleting.
-     * If a migration Role ID is specified the users assign to the current role
+     * If a migration Role ID is specified, the users assigned to the current role
      * will be added to the role of the specified id.
      *
      * @throws PermissionsException
@@ -131,17 +136,19 @@ class PermissionsRepo
             throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
         }
 
-        if ($migrateRoleId !== 0) {
-            $newRole = Role::query()->find($migrateRoleId);
-            if ($newRole) {
-                $users = $role->users()->pluck('id')->toArray();
-                $newRole->users()->sync($users);
+        (new DatabaseTransaction(function () use ($migrateRoleId, $role) {
+            if ($migrateRoleId !== 0) {
+                $newRole = Role::query()->find($migrateRoleId);
+                if ($newRole) {
+                    $users = $role->users()->pluck('id')->toArray();
+                    $newRole->users()->sync($users);
+                }
             }
-        }
 
-        $role->entityPermissions()->delete();
-        $role->jointPermissions()->delete();
-        Activity::add(ActivityType::ROLE_DELETE, $role);
-        $role->delete();
+            $role->entityPermissions()->delete();
+            $role->jointPermissions()->delete();
+            Activity::add(ActivityType::ROLE_DELETE, $role);
+            $role->delete();
+        }))->run();
     }
 }
diff --git a/app/References/CrossLinkParser.php b/app/References/CrossLinkParser.php
index 1fd4c1b3e..3fb00be84 100644
--- a/app/References/CrossLinkParser.php
+++ b/app/References/CrossLinkParser.php
@@ -48,7 +48,7 @@ class CrossLinkParser
     /**
      * Get a list of href values from the given document.
      *
-     * @returns string[]
+     * @return string[]
      */
     protected function getLinksFromContent(string $html): array
     {
diff --git a/app/References/ReferenceChangeContext.php b/app/References/ReferenceChangeContext.php
new file mode 100644
index 000000000..27de0e2d2
--- /dev/null
+++ b/app/References/ReferenceChangeContext.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace BookStack\References;
+
+use BookStack\Entities\Models\Entity;
+
+class ReferenceChangeContext
+{
+    /**
+     * Entity pairs where the first is the old entity and the second is the new entity.
+     * @var array<array{0: Entity, 1: Entity}>
+     */
+    protected array $changes = [];
+
+    public function add(Entity $oldEntity, Entity $newEntity): void
+    {
+        $this->changes[] = [$oldEntity, $newEntity];
+    }
+
+    /**
+     * Get all the new entities from the changes.
+     */
+    public function getNewEntities(): array
+    {
+        return array_column($this->changes, 1);
+    }
+
+    /**
+     * Get all the old entities from the changes.
+     */
+    public function getOldEntities(): array
+    {
+        return array_column($this->changes, 0);
+    }
+
+    public function getNewForOld(Entity $oldEntity): ?Entity
+    {
+        foreach ($this->changes as [$old, $new]) {
+            if ($old->id === $oldEntity->id && $old->type === $oldEntity->type) {
+                return $new;
+            }
+        }
+        return null;
+    }
+}
diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php
index 1c9664f45..8588c6e2c 100644
--- a/app/References/ReferenceFetcher.php
+++ b/app/References/ReferenceFetcher.php
@@ -20,10 +20,10 @@ class ReferenceFetcher
      * Query and return the references pointing to the given entity.
      * Loads the commonly required relations while taking permissions into account.
      */
-    public function getReferencesToEntity(Entity $entity): Collection
+    public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection
     {
         $references = $this->queryReferencesToEntity($entity)->get();
-        $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', true);
+        $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents);
 
         return $references;
     }
diff --git a/app/References/ReferenceUpdater.php b/app/References/ReferenceUpdater.php
index db355f211..42de72fde 100644
--- a/app/References/ReferenceUpdater.php
+++ b/app/References/ReferenceUpdater.php
@@ -3,8 +3,8 @@
 namespace BookStack\References;
 
 use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\HasDescriptionInterface;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\HasHtmlDescription;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\RevisionRepo;
 use BookStack\Util\HtmlDocument;
@@ -29,13 +29,54 @@ class ReferenceUpdater
         }
     }
 
+    /**
+     * Change existing references for a range of entities using the given context.
+     */
+    public function changeReferencesUsingContext(ReferenceChangeContext $context): void
+    {
+        $bindings = [];
+        foreach ($context->getOldEntities() as $old) {
+            $bindings[] = $old->getMorphClass();
+            $bindings[] = $old->id;
+        }
+
+        // No targets to update within the context, so no need to continue.
+        if (count($bindings) < 2) {
+            return;
+        }
+
+        $toReferenceQuery = '(to_type, to_id) IN (' . rtrim(str_repeat('(?,?),', count($bindings) / 2), ',') . ')';
+
+        // Cycle each new entity in the context
+        foreach ($context->getNewEntities() as $new) {
+            // For each, get all references from it which lead to other items within the context of the change
+            $newReferencesInContext = $new->referencesFrom()->whereRaw($toReferenceQuery, $bindings)->get();
+            // For each reference, update the URL and the reference entry
+            foreach ($newReferencesInContext as $reference) {
+                $oldToEntity = $reference->to;
+                $newToEntity = $context->getNewForOld($oldToEntity);
+                if ($newToEntity === null) {
+                    continue;
+                }
+
+                $this->updateReferencesWithinEntity($new, $oldToEntity->getUrl(), $newToEntity->getUrl());
+                if ($newToEntity instanceof Page && $oldToEntity instanceof Page) {
+                    $this->updateReferencesWithinEntity($new, $oldToEntity->getPermalink(), $newToEntity->getPermalink());
+                }
+                $reference->to_id = $newToEntity->id;
+                $reference->to_type = $newToEntity->getMorphClass();
+                $reference->save();
+            }
+        }
+    }
+
     /**
      * @return Reference[]
      */
     protected function getReferencesToUpdate(Entity $entity): array
     {
         /** @var Reference[] $references */
-        $references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
+        $references = $this->referenceFetcher->getReferencesToEntity($entity, true)->values()->all();
 
         if ($entity instanceof Book) {
             $pages = $entity->pages()->get(['id']);
@@ -43,7 +84,7 @@ class ReferenceUpdater
             $children = $pages->concat($chapters);
             foreach ($children as $bookChild) {
                 /** @var Reference[] $childRefs */
-                $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
+                $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild, true)->values()->all();
                 array_push($references, ...$childRefs);
             }
         }
@@ -61,20 +102,18 @@ class ReferenceUpdater
     {
         if ($entity instanceof Page) {
             $this->updateReferencesWithinPage($entity, $oldLink, $newLink);
-            return;
         }
 
-        if (in_array(HasHtmlDescription::class, class_uses($entity))) {
+        if ($entity instanceof HasDescriptionInterface) {
             $this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
         }
     }
 
-    protected function updateReferencesWithinDescription(Entity $entity, string $oldLink, string $newLink): void
+    protected function updateReferencesWithinDescription(Entity&HasDescriptionInterface $entity, string $oldLink, string $newLink): void
     {
-        /** @var HasHtmlDescription&Entity $entity */
-        $entity = (clone $entity)->refresh();
-        $html = $this->updateLinksInHtml($entity->description_html ?: '', $oldLink, $newLink);
-        $entity->description_html = $html;
+        $description = $entity->descriptionInfo();
+        $html = $this->updateLinksInHtml($description->getHtml(true) ?: '', $oldLink, $newLink);
+        $description->set($html);
         $entity->save();
     }
 
diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php
index 79cd8cfab..5de7a5110 100644
--- a/app/Search/SearchApiController.php
+++ b/app/Search/SearchApiController.php
@@ -1,15 +1,18 @@
 <?php
 
+declare(strict_types=1);
+
 namespace BookStack\Search;
 
 use BookStack\Api\ApiEntityListFormatter;
 use BookStack\Entities\Models\Entity;
 use BookStack\Http\ApiController;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 
 class SearchApiController extends ApiController
 {
-    protected $rules = [
+    protected array $rules = [
         'all' => [
             'query' => ['required'],
             'page'  => ['integer', 'min:1'],
@@ -31,11 +34,9 @@ class SearchApiController extends ApiController
      * between: bookshelf, book, chapter & page.
      *
      * The paging parameters and response format emulates a standard listing endpoint
-     * but standard sorting and filtering cannot be done on this endpoint. If a count value
-     * is provided this will only be taken as a suggestion. The results in the response
-     * may currently be up to 4x this value.
+     * but standard sorting and filtering cannot be done on this endpoint.
      */
-    public function all(Request $request)
+    public function all(Request $request): JsonResponse
     {
         $this->validate($request, $this->rules['all']);
 
diff --git a/app/Search/SearchController.php b/app/Search/SearchController.php
index 2fce6a3d5..9586beffb 100644
--- a/app/Search/SearchController.php
+++ b/app/Search/SearchController.php
@@ -7,6 +7,7 @@ use BookStack\Entities\Queries\QueryPopular;
 use BookStack\Entities\Tools\SiblingFetcher;
 use BookStack\Http\Controller;
 use Illuminate\Http\Request;
+use Illuminate\Pagination\LengthAwarePaginator;
 
 class SearchController extends Controller
 {
@@ -23,20 +24,21 @@ class SearchController extends Controller
     {
         $searchOpts = SearchOptions::fromRequest($request);
         $fullSearchString = $searchOpts->toString();
-        $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
-
         $page = intval($request->get('page', '0')) ?: 1;
-        $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
 
         $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
         $formatter->format($results['results']->all(), $searchOpts);
+        $paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
+        $paginator->setPath('/search');
+        $paginator->appends($request->except('page'));
+
+        $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
 
         return view('search.all', [
             'entities'     => $results['results'],
             'totalResults' => $results['total'],
+            'paginator'    => $paginator,
             'searchTerm'   => $fullSearchString,
-            'hasNextPage'  => $results['has_more'],
-            'nextPageLink' => $nextPageLink,
             'options'      => $searchOpts,
         ]);
     }
@@ -128,7 +130,7 @@ class SearchController extends Controller
     }
 
     /**
-     * Search siblings items in the system.
+     * Search sibling items in the system.
      */
     public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher)
     {
diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php
index 36f71f6cc..ce78831ee 100644
--- a/app/Search/SearchIndex.php
+++ b/app/Search/SearchIndex.php
@@ -119,14 +119,14 @@ class SearchIndex
      * Create a scored term array from the given text, where the keys are the terms
      * and the values are their scores.
      *
-     * @returns array<string, int>
+     * @return array<string, int>
      */
     protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array
     {
         $termMap = $this->textToTermCountMap($text);
 
         foreach ($termMap as $term => $count) {
-            $termMap[$term] = floor($count * $scoreAdjustment);
+            $termMap[$term] = intval($count * $scoreAdjustment);
         }
 
         return $termMap;
@@ -136,7 +136,7 @@ class SearchIndex
      * Create a scored term array from the given HTML, where the keys are the terms
      * and the values are their scores.
      *
-     * @returns array<string, int>
+     * @return array<string, int>
      */
     protected function generateTermScoreMapFromHtml(string $html): array
     {
@@ -160,7 +160,9 @@ class SearchIndex
         /** @var DOMNode $child */
         foreach ($doc->getBodyChildren() as $child) {
             $nodeName = $child->nodeName;
-            $termCounts = $this->textToTermCountMap(trim($child->textContent));
+            $text = trim($child->textContent);
+            $text = str_replace("\u{00A0}", ' ', $text);
+            $termCounts = $this->textToTermCountMap($text);
             foreach ($termCounts as $term => $count) {
                 $scoreChange = $count * ($elementScoreAdjustmentMap[$nodeName] ?? 1);
                 $scoresByTerm[$term] = ($scoresByTerm[$term] ?? 0) + $scoreChange;
@@ -175,7 +177,7 @@ class SearchIndex
      *
      * @param Tag[] $tags
      *
-     * @returns array<string, int>
+     * @return array<string, int>
      */
     protected function generateTermScoreMapFromTags(array $tags): array
     {
@@ -197,7 +199,7 @@ class SearchIndex
      * For the given text, return an array where the keys are the unique term words
      * and the values are the frequency of that term.
      *
-     * @returns array<string, int>
+     * @return array<string, int>
      */
     protected function textToTermCountMap(string $text): array
     {
@@ -241,7 +243,7 @@ class SearchIndex
      * For the given entity, Generate an array of term data details.
      * Is the raw term data, not instances of SearchTerm models.
      *
-     * @returns array{term: string, score: float, entity_id: int, entity_type: string}[]
+     * @return array{term: string, score: float, entity_id: int, entity_type: string}[]
      */
     protected function entityToTermDataArray(Entity $entity): array
     {
@@ -277,7 +279,7 @@ class SearchIndex
      *
      * @param array<string, int>[] ...$scoreMaps
      *
-     * @returns array<string, int>
+     * @return array<string, int>
      */
     protected function mergeTermScoreMaps(...$scoreMaps): array
     {
diff --git a/app/Search/SearchOptionSet.php b/app/Search/SearchOptionSet.php
index bd5e5a5b2..844d145e6 100644
--- a/app/Search/SearchOptionSet.php
+++ b/app/Search/SearchOptionSet.php
@@ -14,6 +14,9 @@ class SearchOptionSet
      */
     protected array $options = [];
 
+    /**
+     * @param T[] $options
+     */
     public function __construct(array $options = [])
     {
         $this->options = $options;
diff --git a/app/Search/SearchResultsFormatter.php b/app/Search/SearchResultsFormatter.php
index b06f81e0e..7bb5e03cc 100644
--- a/app/Search/SearchResultsFormatter.php
+++ b/app/Search/SearchResultsFormatter.php
@@ -91,7 +91,7 @@ class SearchResultsFormatter
             $offset = 0;
             $term = mb_strtolower($term);
             $pos = mb_strpos($text, $term, $offset);
-            while ($pos !== false) {
+            while ($pos !== false && count($matchRefs) < 25) {
                 $end = $pos + mb_strlen($term);
                 $matchRefs[$pos] = $end;
                 $offset = $end;
diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php
index 9716f8053..bfb65cf0f 100644
--- a/app/Search/SearchRunner.php
+++ b/app/Search/SearchRunner.php
@@ -4,16 +4,15 @@ namespace BookStack\Search;
 
 use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\Page;
 use BookStack\Entities\Queries\EntityQueries;
+use BookStack\Entities\Tools\EntityHydrator;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Search\Options\TagSearchOption;
 use BookStack\Users\Models\User;
 use Illuminate\Database\Connection;
 use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
-use Illuminate\Database\Eloquent\Collection as EloquentCollection;
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Query\Builder;
+use Illuminate\Database\Query\JoinClause;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
@@ -22,7 +21,7 @@ use WeakMap;
 class SearchRunner
 {
     /**
-     * Retain a cache of score adjusted terms for specific search options.
+     * Retain a cache of score-adjusted terms for specific search options.
      */
     protected WeakMap $termAdjustmentCache;
 
@@ -30,16 +29,15 @@ class SearchRunner
         protected EntityProvider $entityProvider,
         protected PermissionApplicator $permissions,
         protected EntityQueries $entityQueries,
+        protected EntityHydrator $entityHydrator,
     ) {
         $this->termAdjustmentCache = new WeakMap();
     }
 
     /**
      * Search all entities in the system.
-     * The provided count is for each entity to search,
-     * Total returned could be larger and not guaranteed.
      *
-     * @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
+     * @return array{total: int, results: Collection<Entity>}
      */
     public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
     {
@@ -53,32 +51,13 @@ class SearchRunner
             $entityTypesToSearch = explode('|', $filterMap['type']);
         }
 
-        $results = collect();
-        $total = 0;
-        $hasMore = false;
-
-        foreach ($entityTypesToSearch as $entityType) {
-            if (!in_array($entityType, $entityTypes)) {
-                continue;
-            }
-
-            $searchQuery = $this->buildQuery($searchOpts, $entityType);
-            $entityTotal = $searchQuery->count();
-            $searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityType, $page, $count);
-
-            if ($entityTotal > ($page * $count)) {
-                $hasMore = true;
-            }
-
-            $total += $entityTotal;
-            $results = $results->merge($searchResults);
-        }
+        $searchQuery = $this->buildQuery($searchOpts, $entityTypesToSearch);
+        $total = $searchQuery->count();
+        $results = $this->getPageOfDataFromQuery($searchQuery, $page, $count);
 
         return [
             'total'    => $total,
-            'count'    => count($results),
-            'has_more' => $hasMore,
-            'results'  => $results->sortByDesc('score')->values(),
+            'results'  => $results->values(),
         ];
     }
 
@@ -92,17 +71,10 @@ class SearchRunner
         $filterMap = $opts->filters->toValueMap();
         $entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes;
 
-        $results = collect();
-        foreach ($entityTypesToSearch as $entityType) {
-            if (!in_array($entityType, $entityTypes)) {
-                continue;
-            }
-
-            $search = $this->buildQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
-            $results = $results->merge($search);
-        }
+        $filteredTypes = array_intersect($entityTypesToSearch, $entityTypes);
+        $query = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId);
 
-        return $results->sortByDesc('score')->take(20);
+        return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score');
     }
 
     /**
@@ -111,54 +83,45 @@ class SearchRunner
     public function searchChapter(int $chapterId, string $searchString): Collection
     {
         $opts = SearchOptions::fromString($searchString);
-        $pages = $this->buildQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
+        $query = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId);
 
-        return $pages->sortByDesc('score');
+        return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score');
     }
 
     /**
      * Get a page of result data from the given query based on the provided page parameters.
      */
-    protected function getPageOfDataFromQuery(EloquentBuilder $query, string $entityType, int $page = 1, int $count = 20): EloquentCollection
+    protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection
     {
-        $relations = ['tags'];
-
-        if ($entityType === 'page' || $entityType === 'chapter') {
-            $relations['book'] = function (BelongsTo $query) {
-                $query->scopes('visible');
-            };
-        }
-
-        if ($entityType === 'page') {
-            $relations['chapter'] = function (BelongsTo $query) {
-                $query->scopes('visible');
-            };
-        }
-
-        return $query->clone()
-            ->with(array_filter($relations))
+        $entities = $query->clone()
             ->skip(($page - 1) * $count)
             ->take($count)
             ->get();
+
+        $hydrated = $this->entityHydrator->hydrate($entities->all(), true, true);
+
+        return collect($hydrated);
     }
 
     /**
      * Create a search query for an entity.
+     * @param string[] $entityTypes
      */
-    protected function buildQuery(SearchOptions $searchOpts, string $entityType): EloquentBuilder
+    protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder
     {
-        $entityModelInstance = $this->entityProvider->get($entityType);
-        $entityQuery = $this->entityQueries->visibleForList($entityType);
+        $entityQuery = $this->entityQueries->visibleForList()
+            ->whereIn('type', $entityTypes);
 
         // Handle normal search terms
-        $this->applyTermSearch($entityQuery, $searchOpts, $entityType);
+        $this->applyTermSearch($entityQuery, $searchOpts, $entityTypes);
 
         // Handle exact term matching
         foreach ($searchOpts->exacts->all() as $exact) {
-            $filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) {
+            $filter = function (EloquentBuilder $query) use ($exact) {
                 $inputTerm = str_replace('\\', '\\\\', $exact->value);
                 $query->where('name', 'like', '%' . $inputTerm . '%')
-                    ->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
+                    ->orWhere('description', 'like', '%' . $inputTerm . '%')
+                    ->orWhere('text', 'like', '%' . $inputTerm . '%');
             };
 
             $exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter);
@@ -173,7 +136,7 @@ class SearchRunner
         foreach ($searchOpts->filters->all() as $filterOption) {
             $functionName = Str::camel('filter_' . $filterOption->getKey());
             if (method_exists($this, $functionName)) {
-                $this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated);
+                $this->$functionName($entityQuery, $filterOption->value, $filterOption->negated);
             }
         }
 
@@ -183,7 +146,7 @@ class SearchRunner
     /**
      * For the given search query, apply the queries for handling the regular search terms.
      */
-    protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void
+    protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, array $entityTypes): void
     {
         $terms = $options->searches->toValueArray();
         if (count($terms) === 0) {
@@ -200,8 +163,6 @@ class SearchRunner
         ]);
 
         $subQuery->addBinding($scoreSelect['bindings'], 'select');
-
-        $subQuery->where('entity_type', '=', $entityType);
         $subQuery->where(function (Builder $query) use ($terms) {
             foreach ($terms as $inputTerm) {
                 $escapedTerm = str_replace('\\', '\\\\', $inputTerm);
@@ -210,7 +171,10 @@ class SearchRunner
         });
         $subQuery->groupBy('entity_type', 'entity_id');
 
-        $entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
+        $entityQuery->joinSub($subQuery, 's', function (JoinClause $join) {
+            $join->on('s.entity_id', '=', 'entities.id')
+                ->on('s.entity_type', '=', 'entities.type');
+        });
         $entityQuery->addSelect('s.score');
         $entityQuery->orderBy('score', 'desc');
     }
@@ -285,7 +249,7 @@ class SearchRunner
      *
      * @param array<string, int> $termCounts
      *
-     * @return array<string, int>
+     * @return array<string, float>
      */
     protected function rawTermCountsToAdjustments(array $termCounts): array
     {
@@ -338,7 +302,7 @@ class SearchRunner
         $option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter);
     }
 
-    protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void
+    protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string|callable $column, string|null $operator, mixed $value): void
     {
         if ($negated) {
             $query->whereNot($column, $operator, $value);
@@ -350,31 +314,31 @@ class SearchRunner
     /**
      * Custom entity search filters.
      */
-    protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
+    protected function filterUpdatedAfter(EloquentBuilder $query, string $input, bool $negated): void
     {
         $date = date_create($input);
         $this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date);
     }
 
-    protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
+    protected function filterUpdatedBefore(EloquentBuilder $query, string $input, bool $negated): void
     {
         $date = date_create($input);
         $this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date);
     }
 
-    protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
+    protected function filterCreatedAfter(EloquentBuilder $query, string $input, bool $negated): void
     {
         $date = date_create($input);
         $this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date);
     }
 
-    protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterCreatedBefore(EloquentBuilder $query, string $input, bool $negated)
     {
         $date = date_create($input);
         $this->applyNegatableWhere($query, $negated, 'created_at', '<', $date);
     }
 
-    protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterCreatedBy(EloquentBuilder $query, string $input, bool $negated)
     {
         $userSlug = $input === 'me' ? user()->slug : trim($input);
         $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
@@ -383,7 +347,7 @@ class SearchRunner
         }
     }
 
-    protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterUpdatedBy(EloquentBuilder $query, string $input, bool $negated)
     {
         $userSlug = $input === 'me' ? user()->slug : trim($input);
         $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
@@ -392,7 +356,7 @@ class SearchRunner
         }
     }
 
-    protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterOwnedBy(EloquentBuilder $query, string $input, bool $negated)
     {
         $userSlug = $input === 'me' ? user()->slug : trim($input);
         $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
@@ -401,27 +365,30 @@ class SearchRunner
         }
     }
 
-    protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterInName(EloquentBuilder $query, string $input, bool $negated)
     {
         $this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%');
     }
 
-    protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterInTitle(EloquentBuilder $query, string $input, bool $negated)
     {
-        $this->filterInName($query, $model, $input, $negated);
+        $this->filterInName($query, $input, $negated);
     }
 
-    protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterInBody(EloquentBuilder $query, string $input, bool $negated)
     {
-        $this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%');
+        $this->applyNegatableWhere($query, $negated, function (EloquentBuilder $query) use ($input) {
+            $query->where('description', 'like', '%' . $input . '%')
+                ->orWhere('text', 'like', '%' . $input . '%');
+        }, null, null);
     }
 
-    protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterIsRestricted(EloquentBuilder $query, string $input, bool $negated)
     {
         $negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions');
     }
 
-    protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterViewedByMe(EloquentBuilder $query, string $input, bool $negated)
     {
         $filter = function ($query) {
             $query->where('user_id', '=', user()->id);
@@ -430,7 +397,7 @@ class SearchRunner
         $negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter);
     }
 
-    protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterNotViewedByMe(EloquentBuilder $query, string $input, bool $negated)
     {
         $filter = function ($query) {
             $query->where('user_id', '=', user()->id);
@@ -439,31 +406,30 @@ class SearchRunner
         $negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter);
     }
 
-    protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterIsTemplate(EloquentBuilder $query, string $input, bool $negated)
     {
-        if ($model instanceof Page) {
-            $this->applyNegatableWhere($query, $negated, 'template', '=', true);
-        }
+        $this->applyNegatableWhere($query, $negated, 'template', '=', true);
     }
 
-    protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
+    protected function filterSortBy(EloquentBuilder $query, string $input, bool $negated)
     {
         $functionName = Str::camel('sort_by_' . $input);
         if (method_exists($this, $functionName)) {
-            $this->$functionName($query, $model, $negated);
+            $this->$functionName($query, $negated);
         }
     }
 
     /**
      * Sorting filter options.
      */
-    protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated)
+    protected function sortByLastCommented(EloquentBuilder $query, bool $negated)
     {
         $commentsTable = DB::getTablePrefix() . 'comments';
-        $morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
-        $commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments');
+        $commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c2.created_at IS NULL) as comments');
 
-        $query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id'))
-            ->orderBy('last_commented', $negated ? 'asc' : 'desc');
+        $query->join($commentQuery, function (JoinClause $join) {
+            $join->on('entities.id', '=', 'comments.commentable_id')
+                ->on('entities.type', '=', 'comments.commentable_type');
+        })->orderBy('last_commented', $negated ? 'asc' : 'desc');
     }
 }
diff --git a/app/Settings/MaintenanceController.php b/app/Settings/MaintenanceController.php
index 0382ae08a..b2b2226bf 100644
--- a/app/Settings/MaintenanceController.php
+++ b/app/Settings/MaintenanceController.php
@@ -3,8 +3,10 @@
 namespace BookStack\Settings;
 
 use BookStack\Activity\ActivityType;
+use BookStack\App\AppVersion;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\References\ReferenceStore;
 use BookStack\Uploads\ImageService;
 use Illuminate\Http\Request;
@@ -16,17 +18,14 @@ class MaintenanceController extends Controller
      */
     public function index(TrashCan $trashCan)
     {
-        $this->checkPermission('settings-manage');
+        $this->checkPermission(Permission::SettingsManage);
         $this->setPageTitle(trans('settings.maint'));
 
-        // Get application version
-        $version = trim(file_get_contents(base_path('version')));
-
         // Recycle bin details
         $recycleStats = $trashCan->getTrashedCounts();
 
         return view('settings.maintenance', [
-            'version'      => $version,
+            'version'      => AppVersion::get(),
             'recycleStats' => $recycleStats,
         ]);
     }
@@ -36,7 +35,7 @@ class MaintenanceController extends Controller
      */
     public function cleanupImages(Request $request, ImageService $imageService)
     {
-        $this->checkPermission('settings-manage');
+        $this->checkPermission(Permission::SettingsManage);
         $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'cleanup-images');
 
         $checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
@@ -64,7 +63,7 @@ class MaintenanceController extends Controller
      */
     public function sendTestEmail()
     {
-        $this->checkPermission('settings-manage');
+        $this->checkPermission(Permission::SettingsManage);
         $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
 
         try {
@@ -83,7 +82,7 @@ class MaintenanceController extends Controller
      */
     public function regenerateReferences(ReferenceStore $referenceStore)
     {
-        $this->checkPermission('settings-manage');
+        $this->checkPermission(Permission::SettingsManage);
         $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references');
 
         try {
diff --git a/app/Settings/SettingController.php b/app/Settings/SettingController.php
index 1c5f13bc6..f89bc6dab 100644
--- a/app/Settings/SettingController.php
+++ b/app/Settings/SettingController.php
@@ -3,7 +3,9 @@
 namespace BookStack\Settings;
 
 use BookStack\Activity\ActivityType;
+use BookStack\App\AppVersion;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Users\Models\User;
 use Illuminate\Http\Request;
 
@@ -23,15 +25,12 @@ class SettingController extends Controller
     public function category(string $category)
     {
         $this->ensureCategoryExists($category);
-        $this->checkPermission('settings-manage');
+        $this->checkPermission(Permission::SettingsManage);
         $this->setPageTitle(trans('settings.settings'));
 
-        // Get application version
-        $version = trim(file_get_contents(base_path('version')));
-
         return view('settings.categories.' . $category, [
             'category'  => $category,
-            'version'   => $version,
+            'version'   => AppVersion::get(),
             'guestUser' => User::getGuest(),
         ]);
     }
@@ -43,7 +42,7 @@ class SettingController extends Controller
     {
         $this->ensureCategoryExists($category);
         $this->preventAccessInDemoMode();
-        $this->checkPermission('settings-manage');
+        $this->checkPermission(Permission::SettingsManage);
         $this->validate($request, [
             'app_logo' => ['nullable', ...$this->getImageValidationRules()],
             'app_icon' => ['nullable', ...$this->getImageValidationRules()],
diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php
index 479d19724..7e2ee5465 100644
--- a/app/Sorting/BookSortController.php
+++ b/app/Sorting/BookSortController.php
@@ -7,6 +7,8 @@ use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Facades\Activity;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
+use BookStack\Util\DatabaseTransaction;
 use Illuminate\Http\Request;
 
 class BookSortController extends Controller
@@ -22,7 +24,7 @@ class BookSortController extends Controller
     public function show(string $bookSlug)
     {
         $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
-        $this->checkOwnablePermission('book-update', $book);
+        $this->checkOwnablePermission(Permission::BookUpdate, $book);
 
         $bookChildren = (new BookContents($book))->getTree(false);
 
@@ -50,21 +52,23 @@ class BookSortController extends Controller
     public function update(Request $request, BookSorter $sorter, string $bookSlug)
     {
         $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
-        $this->checkOwnablePermission('book-update', $book);
+        $this->checkOwnablePermission(Permission::BookUpdate, $book);
         $loggedActivityForBook = false;
 
         // Sort via map
         if ($request->filled('sort-tree')) {
-            $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
-            $booksInvolved = $sorter->sortUsingMap($sortMap);
+            (new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) {
+                $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
+                $booksInvolved = $sorter->sortUsingMap($sortMap);
 
-            // Rebuild permissions and add activity for involved books.
-            foreach ($booksInvolved as $bookInvolved) {
-                Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
-                if ($bookInvolved->id === $book->id) {
-                    $loggedActivityForBook = true;
+                // Add activity for involved books.
+                foreach ($booksInvolved as $bookInvolved) {
+                    Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
+                    if ($bookInvolved->id === $book->id) {
+                        $loggedActivityForBook = true;
+                    }
                 }
-            }
+            }))->run();
         }
 
         if ($request->filled('auto-sort')) {
diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php
index 6710f070a..b4f93d47b 100644
--- a/app/Sorting/BookSorter.php
+++ b/app/Sorting/BookSorter.php
@@ -2,18 +2,20 @@
 
 namespace BookStack\Sorting;
 
-use BookStack\App\Model;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\BookChild;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Queries\EntityQueries;
+use BookStack\Entities\Tools\ParentChanger;
+use BookStack\Permissions\Permission;
 
 class BookSorter
 {
     public function __construct(
         protected EntityQueries $queries,
+        protected ParentChanger $parentChanger,
     ) {
     }
 
@@ -33,22 +35,22 @@ class BookSorter
      */
     public function runBookAutoSort(Book $book): void
     {
-        $set = $book->sortRule;
-        if (!$set) {
+        $rule = $book->sortRule()->first();
+        if (!($rule instanceof SortRule)) {
             return;
         }
 
         $sortFunctions = array_map(function (SortRuleOperation $op) {
             return $op->getSortFunction();
-        }, $set->getOperations());
+        }, $rule->getOperations());
 
         $chapters = $book->chapters()
-            ->with('pages:id,name,priority,created_at,updated_at,chapter_id')
+            ->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at')
             ->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
 
         /** @var (Chapter|Book)[] $topItems */
         $topItems = [
-            ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
+            ...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']),
             ...$chapters,
         ];
 
@@ -79,7 +81,7 @@ class BookSorter
      * Sort the books content using the given sort map.
      * Returns a list of books that were involved in the operation.
      *
-     * @returns Book[]
+     * @return Book[]
      */
     public function sortUsingMap(BookSortMap $sortMap): array
     {
@@ -155,11 +157,12 @@ class BookSorter
 
         // Action the required changes
         if ($bookChanged) {
-            $model->changeBook($newBook->id);
+            $this->parentChanger->changeBook($model, $newBook->id);
         }
 
         if ($model instanceof Page && $chapterChanged) {
             $model->chapter_id = $newChapter->id ?? 0;
+            $model->unsetRelation('chapter');
         }
 
         if ($priorityChanged) {
@@ -188,11 +191,11 @@ class BookSorter
 
         $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
         if ($model instanceof Chapter) {
-            $hasPermission = userCan('book-update', $currentParent)
-                && userCan('book-update', $newBook)
-                && userCan('chapter-update', $model)
-                && (!$hasNewParent || userCan('chapter-create', $newBook))
-                && (!$hasNewParent || userCan('chapter-delete', $model));
+            $hasPermission = userCan(Permission::BookUpdate, $currentParent)
+                && userCan(Permission::BookUpdate, $newBook)
+                && userCan(Permission::ChapterUpdate, $model)
+                && (!$hasNewParent || userCan(Permission::ChapterCreate, $newBook))
+                && (!$hasNewParent || userCan(Permission::ChapterDelete, $model));
 
             if (!$hasPermission) {
                 return false;
@@ -211,13 +214,13 @@ class BookSorter
                 return false;
             }
 
-            $hasPageEditPermission = userCan('page-update', $model);
+            $hasPageEditPermission = userCan(Permission::PageUpdate, $model);
             $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
             $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
             $hasNewParentPermission = userCan($newParentPermission, $newParent);
 
-            $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
-            $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
+            $hasDeletePermissionIfMoving = (!$hasNewParent || userCan(Permission::PageDelete, $model));
+            $hasCreatePermissionIfMoving = (!$hasNewParent || userCan(Permission::PageCreate, $newParent));
 
             $hasPermission = $hasCurrentParentPermission
                 && $newParentInRightLocation
diff --git a/app/Sorting/SortRule.php b/app/Sorting/SortRule.php
index 45e5514fd..bf53365a2 100644
--- a/app/Sorting/SortRule.php
+++ b/app/Sorting/SortRule.php
@@ -50,7 +50,7 @@ class SortRule extends Model implements Loggable
 
     public function books(): HasMany
     {
-        return $this->hasMany(Book::class);
+        return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id');
     }
 
     public static function allByName(): Collection
diff --git a/app/Sorting/SortRuleController.php b/app/Sorting/SortRuleController.php
index 96b8e8ef5..65e1cba09 100644
--- a/app/Sorting/SortRuleController.php
+++ b/app/Sorting/SortRuleController.php
@@ -3,14 +3,16 @@
 namespace BookStack\Sorting;
 
 use BookStack\Activity\ActivityType;
+use BookStack\Entities\Models\EntityContainerData;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use Illuminate\Http\Request;
 
 class SortRuleController extends Controller
 {
     public function __construct()
     {
-        $this->middleware('can:settings-manage');
+        $this->middleware(Permission::SettingsManage->middleware());
     }
 
     public function create()
@@ -29,7 +31,7 @@ class SortRuleController extends Controller
 
         $operations = SortRuleOperation::fromSequence($request->input('sequence'));
         if (count($operations) === 0) {
-            return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']);
+            return redirect('/settings/sorting/rules/new')->withInput()->withErrors(['sequence' => 'No operations set.']);
         }
 
         $rule = new SortRule();
@@ -87,7 +89,9 @@ class SortRuleController extends Controller
 
         if ($booksAssigned > 0) {
             if ($confirmed) {
-                $rule->books()->update(['sort_rule_id' => null]);
+                EntityContainerData::query()
+                    ->where('sort_rule_id', $rule->id)
+                    ->update(['sort_rule_id' => null]);
             } else {
                 $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
             }
diff --git a/app/Theming/ThemeEvents.php b/app/Theming/ThemeEvents.php
index 2d4900c96..44630acae 100644
--- a/app/Theming/ThemeEvents.php
+++ b/app/Theming/ThemeEvents.php
@@ -62,7 +62,7 @@ class ThemeEvents
      *
      * @param string $authSystem
      * @param array $userData
-     * @returns bool|null
+     * @return bool|null
      */
     const AUTH_PRE_REGISTER = 'auth_pre_register';
 
@@ -83,7 +83,7 @@ class ThemeEvents
      * If the listener returns a non-null value, that will be used as an environment instead.
      *
      * @param \League\CommonMark\Environment\Environment $environment
-     * @returns \League\CommonMark\Environment\Environment|null
+     * @return \League\CommonMark\Environment\Environment|null
      */
     const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
 
@@ -96,7 +96,7 @@ class ThemeEvents
      *
      * @param array $idTokenData
      * @param array $accessTokenData
-     * @returns array|null
+     * @return array|null
      */
     const OIDC_ID_TOKEN_PRE_VALIDATE = 'oidc_id_token_pre_validate';
 
@@ -142,7 +142,7 @@ class ThemeEvents
      * Return values, if provided, will be used as a new response to use.
      *
      * @param \Illuminate\Http\Request $request
-     * @returns \Illuminate\Http\Response|null
+     * @return \Illuminate\Http\Response|null
      */
     const WEB_MIDDLEWARE_BEFORE = 'web_middleware_before';
 
@@ -154,7 +154,7 @@ class ThemeEvents
      *
      * @param \Illuminate\Http\Request $request
      * @param \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\BinaryFileResponse $response
-     * @returns \Illuminate\Http\Response|null
+     * @return \Illuminate\Http\Response|null
      */
     const WEB_MIDDLEWARE_AFTER = 'web_middleware_after';
 
@@ -173,7 +173,7 @@ class ThemeEvents
      * @param string|\BookStack\Activity\Models\Loggable $detail
      * @param \BookStack\Users\Models\User $initiator
      * @param int $initiatedTime
-     * @returns array|null
+     * @return array|null
      */
     const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
 }
diff --git a/app/Translation/LocaleManager.php b/app/Translation/LocaleManager.php
index 86e712bee..d23c23610 100644
--- a/app/Translation/LocaleManager.php
+++ b/app/Translation/LocaleManager.php
@@ -50,6 +50,7 @@ class LocaleManager
         'ku'          => 'ku_TR',
         'lt'          => 'lt_LT',
         'lv'          => 'lv_LV',
+        'ne'          => 'ne_NP',
         'nb'          => 'nb_NO',
         'nl'          => 'nl_NL',
         'nn'          => 'nn_NO',
diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php
index 57d7cb334..05227243a 100644
--- a/app/Uploads/Attachment.php
+++ b/app/Uploads/Attachment.php
@@ -8,6 +8,7 @@ use BookStack\Entities\Models\Page;
 use BookStack\Permissions\Models\JointPermission;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Users\Models\HasCreatorAndUpdater;
+use BookStack\Users\Models\OwnableInterface;
 use BookStack\Users\Models\User;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -27,7 +28,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
  *
  * @method static Entity|Builder visible()
  */
-class Attachment extends Model
+class Attachment extends Model implements OwnableInterface
 {
     use HasCreatorAndUpdater;
     use HasFactory;
diff --git a/app/Uploads/Controllers/AttachmentApiController.php b/app/Uploads/Controllers/AttachmentApiController.php
index 87e00257c..ea3c4a962 100644
--- a/app/Uploads/Controllers/AttachmentApiController.php
+++ b/app/Uploads/Controllers/AttachmentApiController.php
@@ -2,9 +2,11 @@
 
 namespace BookStack\Uploads\Controllers;
 
+use BookStack\Entities\EntityExistsRule;
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Exceptions\FileUploadException;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\Attachment;
 use BookStack\Uploads\AttachmentService;
 use Exception;
@@ -45,12 +47,12 @@ class AttachmentApiController extends ApiController
      */
     public function create(Request $request)
     {
-        $this->checkPermission('attachment-create-all');
+        $this->checkPermission(Permission::AttachmentCreateAll);
         $requestData = $this->validate($request, $this->rules()['create']);
 
         $pageId = $request->get('uploaded_to');
         $page = $this->pageQueries->findVisibleByIdOrFail($pageId);
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
 
         if ($request->hasFile('file')) {
             $uploadedFile = $request->file('file');
@@ -137,9 +139,9 @@ class AttachmentApiController extends ApiController
             $attachment->uploaded_to = $requestData['uploaded_to'];
         }
 
-        $this->checkOwnablePermission('page-view', $page);
-        $this->checkOwnablePermission('page-update', $page);
-        $this->checkOwnablePermission('attachment-update', $attachment);
+        $this->checkOwnablePermission(Permission::PageView, $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
+        $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment);
 
         if ($request->hasFile('file')) {
             $uploadedFile = $request->file('file');
@@ -160,7 +162,7 @@ class AttachmentApiController extends ApiController
     {
         /** @var Attachment $attachment */
         $attachment = Attachment::visible()->findOrFail($id);
-        $this->checkOwnablePermission('attachment-delete', $attachment);
+        $this->checkOwnablePermission(Permission::AttachmentDelete, $attachment);
 
         $this->attachmentService->deleteFile($attachment);
 
@@ -172,13 +174,13 @@ class AttachmentApiController extends ApiController
         return [
             'create' => [
                 'name'        => ['required', 'string', 'min:1', 'max:255'],
-                'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
+                'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')],
                 'file'        => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
                 'link'        => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'],
             ],
             'update' => [
                 'name'        => ['string', 'min:1', 'max:255'],
-                'uploaded_to' => ['integer', 'exists:pages,id'],
+                'uploaded_to' => ['integer', new EntityExistsRule('page')],
                 'file'        => $this->attachmentService->getFileValidationRules(),
                 'link'        => ['string', 'min:1', 'max:2000', 'safe_url'],
             ],
diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php
index 809cdfa58..9c60fa415 100644
--- a/app/Uploads/Controllers/AttachmentController.php
+++ b/app/Uploads/Controllers/AttachmentController.php
@@ -2,11 +2,13 @@
 
 namespace BookStack\Uploads\Controllers;
 
+use BookStack\Entities\EntityExistsRule;
 use BookStack\Entities\Queries\PageQueries;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\FileUploadException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\Attachment;
 use BookStack\Uploads\AttachmentService;
 use Exception;
@@ -33,15 +35,15 @@ class AttachmentController extends Controller
     public function upload(Request $request)
     {
         $this->validate($request, [
-            'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
+            'uploaded_to' => ['required', 'integer',  new EntityExistsRule('page')],
             'file'        => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
         ]);
 
         $pageId = $request->get('uploaded_to');
         $page = $this->pageQueries->findVisibleByIdOrFail($pageId);
 
-        $this->checkPermission('attachment-create-all');
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkPermission(Permission::AttachmentCreateAll);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
 
         $uploadedFile = $request->file('file');
 
@@ -67,9 +69,9 @@ class AttachmentController extends Controller
 
         /** @var Attachment $attachment */
         $attachment = Attachment::query()->findOrFail($attachmentId);
-        $this->checkOwnablePermission('view', $attachment->page);
-        $this->checkOwnablePermission('page-update', $attachment->page);
-        $this->checkOwnablePermission('attachment-create', $attachment);
+        $this->checkOwnablePermission(Permission::PageView, $attachment->page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $attachment->page);
+        $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment);
 
         $uploadedFile = $request->file('file');
 
@@ -90,8 +92,8 @@ class AttachmentController extends Controller
         /** @var Attachment $attachment */
         $attachment = Attachment::query()->findOrFail($attachmentId);
 
-        $this->checkOwnablePermission('page-update', $attachment->page);
-        $this->checkOwnablePermission('attachment-create', $attachment);
+        $this->checkOwnablePermission(Permission::PageUpdate, $attachment->page);
+        $this->checkOwnablePermission(Permission::AttachmentCreate, $attachment);
 
         return view('attachments.manager-edit-form', [
             'attachment' => $attachment,
@@ -118,9 +120,9 @@ class AttachmentController extends Controller
             ]), 422);
         }
 
-        $this->checkOwnablePermission('page-view', $attachment->page);
-        $this->checkOwnablePermission('page-update', $attachment->page);
-        $this->checkOwnablePermission('attachment-update', $attachment);
+        $this->checkOwnablePermission(Permission::PageView, $attachment->page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $attachment->page);
+        $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment);
 
         $attachment = $this->attachmentService->updateFile($attachment, [
             'name' => $request->get('attachment_edit_name'),
@@ -143,7 +145,7 @@ class AttachmentController extends Controller
 
         try {
             $this->validate($request, [
-                'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
+                'attachment_link_uploaded_to' => ['required', 'integer',  new EntityExistsRule('page')],
                 'attachment_link_name'        => ['required', 'string', 'min:1', 'max:255'],
                 'attachment_link_url'         => ['required', 'string', 'min:1', 'max:2000', 'safe_url'],
             ]);
@@ -156,8 +158,8 @@ class AttachmentController extends Controller
 
         $page = $this->pageQueries->findVisibleByIdOrFail($pageId);
 
-        $this->checkPermission('attachment-create-all');
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkPermission(Permission::AttachmentCreateAll);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
 
         $attachmentName = $request->get('attachment_link_name');
         $link = $request->get('attachment_link_url');
@@ -176,7 +178,6 @@ class AttachmentController extends Controller
     public function listForPage(int $pageId)
     {
         $page = $this->pageQueries->findVisibleByIdOrFail($pageId);
-        $this->checkOwnablePermission('page-view', $page);
 
         return view('attachments.manager-list', [
             'attachments' => $page->attachments->all(),
@@ -195,7 +196,7 @@ class AttachmentController extends Controller
             'order' => ['required', 'array'],
         ]);
         $page = $this->pageQueries->findVisibleByIdOrFail($pageId);
-        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission(Permission::PageUpdate, $page);
 
         $attachmentOrder = $request->get('order');
         $this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
@@ -220,7 +221,7 @@ class AttachmentController extends Controller
             throw new NotFoundException(trans('errors.attachment_not_found'));
         }
 
-        $this->checkOwnablePermission('page-view', $page);
+        $this->checkOwnablePermission(Permission::PageView, $page);
 
         if ($attachment->external) {
             return redirect($attachment->path);
@@ -246,7 +247,7 @@ class AttachmentController extends Controller
     {
         /** @var Attachment $attachment */
         $attachment = Attachment::query()->findOrFail($attachmentId);
-        $this->checkOwnablePermission('attachment-delete', $attachment);
+        $this->checkOwnablePermission(Permission::AttachmentDelete, $attachment);
         $this->attachmentService->deleteFile($attachment);
 
         return response()->json(['message' => trans('entities.attachments_deleted')]);
diff --git a/app/Uploads/Controllers/DrawioImageController.php b/app/Uploads/Controllers/DrawioImageController.php
index 6293da4f7..f44acd997 100644
--- a/app/Uploads/Controllers/DrawioImageController.php
+++ b/app/Uploads/Controllers/DrawioImageController.php
@@ -4,6 +4,7 @@ namespace BookStack\Uploads\Controllers;
 
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\ImageRepo;
 use BookStack\Uploads\ImageResizer;
 use BookStack\Util\OutOfMemoryHandler;
@@ -57,7 +58,7 @@ class DrawioImageController extends Controller
             'uploaded_to' => ['required', 'integer'],
         ]);
 
-        $this->checkPermission('image-create-all');
+        $this->checkPermission(Permission::ImageCreateAll);
         $imageBase64Data = $request->get('image');
 
         try {
@@ -81,7 +82,7 @@ class DrawioImageController extends Controller
             return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
         }
 
-        if ($image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
+        if ($image->type !== 'drawio' || !userCan(Permission::PageView, $image->getPage())) {
             return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
         }
 
diff --git a/app/Uploads/Controllers/GalleryImageController.php b/app/Uploads/Controllers/GalleryImageController.php
index 1bc9da2d7..745efcde8 100644
--- a/app/Uploads/Controllers/GalleryImageController.php
+++ b/app/Uploads/Controllers/GalleryImageController.php
@@ -4,6 +4,7 @@ namespace BookStack\Uploads\Controllers;
 
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\ImageRepo;
 use BookStack\Uploads\ImageResizer;
 use BookStack\Util\OutOfMemoryHandler;
@@ -52,7 +53,7 @@ class GalleryImageController extends Controller
      */
     public function create(Request $request)
     {
-        $this->checkPermission('image-create-all');
+        $this->checkPermission(Permission::ImageCreateAll);
 
         try {
             $this->validate($request, [
diff --git a/app/Uploads/Controllers/ImageController.php b/app/Uploads/Controllers/ImageController.php
index c68ffdf6b..da67639c1 100644
--- a/app/Uploads/Controllers/ImageController.php
+++ b/app/Uploads/Controllers/ImageController.php
@@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\NotifyException;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
 use BookStack\Uploads\ImageResizer;
@@ -50,7 +51,7 @@ class ImageController extends Controller
 
         $image = $this->imageRepo->getById($id);
         $this->checkImagePermission($image);
-        $this->checkOwnablePermission('image-update', $image);
+        $this->checkOwnablePermission(Permission::ImageUpdate, $image);
 
         $image = $this->imageRepo->updateImageDetails($image, $data);
 
@@ -71,7 +72,7 @@ class ImageController extends Controller
 
         $image = $this->imageRepo->getById($id);
         $this->checkImagePermission($image);
-        $this->checkOwnablePermission('image-update', $image);
+        $this->checkOwnablePermission(Permission::ImageUpdate, $image);
         $file = $request->file('file');
 
         new OutOfMemoryHandler(function () {
@@ -125,7 +126,7 @@ class ImageController extends Controller
     public function destroy(string $id)
     {
         $image = $this->imageRepo->getById($id);
-        $this->checkOwnablePermission('image-delete', $image);
+        $this->checkOwnablePermission(Permission::ImageDelete, $image);
         $this->checkImagePermission($image);
 
         $this->imageRepo->destroyImage($image);
@@ -140,7 +141,7 @@ class ImageController extends Controller
     {
         $image = $this->imageRepo->getById($id);
         $this->checkImagePermission($image);
-        $this->checkOwnablePermission('image-update', $image);
+        $this->checkOwnablePermission(Permission::ImageUpdate, $image);
 
         new OutOfMemoryHandler(function () {
             return $this->jsonError(trans('errors.image_thumbnail_memory_limit'));
@@ -163,7 +164,7 @@ class ImageController extends Controller
 
         $relatedPage = $image->getPage();
         if ($relatedPage) {
-            $this->checkOwnablePermission('page-view', $relatedPage);
+            $this->checkOwnablePermission(Permission::PageView, $relatedPage);
         }
     }
 }
diff --git a/app/Uploads/Controllers/ImageGalleryApiController.php b/app/Uploads/Controllers/ImageGalleryApiController.php
index 6d4657a7a..c4168a77e 100644
--- a/app/Uploads/Controllers/ImageGalleryApiController.php
+++ b/app/Uploads/Controllers/ImageGalleryApiController.php
@@ -3,10 +3,13 @@
 namespace BookStack\Uploads\Controllers;
 
 use BookStack\Entities\Queries\PageQueries;
+use BookStack\Exceptions\NotFoundException;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
 use BookStack\Uploads\ImageResizer;
+use BookStack\Uploads\ImageService;
 use Illuminate\Http\Request;
 
 class ImageGalleryApiController extends ApiController
@@ -19,6 +22,7 @@ class ImageGalleryApiController extends ApiController
         protected ImageRepo $imageRepo,
         protected ImageResizer $imageResizer,
         protected PageQueries $pageQueries,
+        protected ImageService $imageService,
     ) {
     }
 
@@ -31,6 +35,9 @@ class ImageGalleryApiController extends ApiController
                 'image' => ['required', 'file', ...$this->getImageValidationRules()],
                 'name'  => ['string', 'max:180'],
             ],
+            'readDataForUrl' => [
+                'url' => ['required', 'string', 'url'],
+            ],
             'update' => [
                 'name'  => ['string', 'max:180'],
                 'image' => ['file', ...$this->getImageValidationRules()],
@@ -65,7 +72,7 @@ class ImageGalleryApiController extends ApiController
      */
     public function create(Request $request)
     {
-        $this->checkPermission('image-create-all');
+        $this->checkPermission(Permission::ImageCreateAll);
         $data = $this->validate($request, $this->rules()['create']);
         $page = $this->pageQueries->findVisibleByIdOrFail($data['uploaded_to']);
 
@@ -84,7 +91,8 @@ class ImageGalleryApiController extends ApiController
      * The "thumbs" response property contains links to scaled variants that BookStack may use in its UI.
      * The "content" response property provides HTML and Markdown content, in the format that BookStack
      * would typically use by default to add the image in page content, as a convenience.
-     * Actual image file data is not provided but can be fetched via the "url" response property.
+     * Actual image file data is not provided but can be fetched via the "url" response property or by
+     * using the "read-data" endpoint.
      */
     public function read(string $id)
     {
@@ -93,6 +101,37 @@ class ImageGalleryApiController extends ApiController
         return response()->json($this->formatForSingleResponse($image));
     }
 
+    /**
+     * Read the image file data for a single image in the system.
+     * The returned response will be a stream of image data instead of a JSON response.
+     */
+    public function readData(string $id)
+    {
+        $image = Image::query()->scopes(['visible'])->findOrFail($id);
+
+        return $this->imageService->streamImageFromStorageResponse('gallery', $image->path);
+    }
+
+    /**
+     * Read the image file data for a single image in the system, using the provided URL
+     * to identify the image instead of its ID, which is provided as a "URL" query parameter.
+     * The returned response will be a stream of image data instead of a JSON response.
+     */
+    public function readDataForUrl(Request $request)
+    {
+        $data = $this->validate($request, $this->rules()['readDataForUrl']);
+        $basePath = url('/uploads/images/');
+        $imagePath = str_replace($basePath, '', $data['url']);
+
+        if (!$this->imageService->pathAccessible($imagePath)) {
+            throw (new NotFoundException(trans('errors.image_not_found')))
+                ->setSubtitle(trans('errors.image_not_found_subtitle'))
+                ->setDetails(trans('errors.image_not_found_details'));
+        }
+
+        return $this->imageService->streamImageFromStorageResponse('gallery', $imagePath);
+    }
+
     /**
      * Update the details of an existing image in the system.
      * Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a
@@ -102,8 +141,8 @@ class ImageGalleryApiController extends ApiController
     {
         $data = $this->validate($request, $this->rules()['update']);
         $image = $this->imageRepo->getById($id);
-        $this->checkOwnablePermission('page-view', $image->getPage());
-        $this->checkOwnablePermission('image-update', $image);
+        $this->checkOwnablePermission(Permission::PageView, $image->getPage());
+        $this->checkOwnablePermission(Permission::ImageUpdate, $image);
 
         $this->imageRepo->updateImageDetails($image, $data);
         if (isset($data['image'])) {
@@ -121,8 +160,8 @@ class ImageGalleryApiController extends ApiController
     public function delete(string $id)
     {
         $image = $this->imageRepo->getById($id);
-        $this->checkOwnablePermission('page-view', $image->getPage());
-        $this->checkOwnablePermission('image-delete', $image);
+        $this->checkOwnablePermission(Permission::PageView, $image->getPage());
+        $this->checkOwnablePermission(Permission::ImageDelete, $image);
         $this->imageRepo->destroyImage($image);
 
         return response('', 204);
@@ -146,10 +185,10 @@ class ImageGalleryApiController extends ApiController
             $data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>";
             $data['content']['markdown'] = $data['content']['html'];
         } else {
-            $escapedDisplayThumb = htmlentities($image->thumbs['display']);
+            $escapedDisplayThumb = htmlentities($image->getAttribute('thumbs')['display']);
             $data['content']['html'] = "<a href=\"{$escapedUrl}\" target=\"_blank\"><img src=\"{$escapedDisplayThumb}\" alt=\"{$escapedName}\"></a>";
             $mdEscapedName = str_replace(']', '', str_replace('[', '', $image->name));
-            $mdEscapedThumb = str_replace(']', '', str_replace('[', '', $image->thumbs['display']));
+            $mdEscapedThumb = str_replace(']', '', str_replace('[', '', $image->getAttribute('thumbs')['display']));
             $data['content']['markdown'] = "![{$mdEscapedName}]({$mdEscapedThumb})";
         }
 
diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php
index 0a267a644..81b6db6fd 100644
--- a/app/Uploads/Image.php
+++ b/app/Uploads/Image.php
@@ -7,21 +7,22 @@ use BookStack\Entities\Models\Page;
 use BookStack\Permissions\Models\JointPermission;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Users\Models\HasCreatorAndUpdater;
+use BookStack\Users\Models\OwnableInterface;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 
 /**
- * @property int    $id
- * @property string $name
- * @property string $url
- * @property string $path
- * @property string $type
- * @property int    $uploaded_to
- * @property int    $created_by
- * @property int    $updated_by
+ * @property int      $id
+ * @property string   $name
+ * @property string   $url
+ * @property string   $path
+ * @property string   $type
+ * @property int|null $uploaded_to
+ * @property int      $created_by
+ * @property int      $updated_by
  */
-class Image extends Model
+class Image extends Model implements OwnableInterface
 {
     use HasFactory;
     use HasCreatorAndUpdater;
@@ -41,7 +42,9 @@ class Image extends Model
      */
     public function scopeVisible(Builder $query): Builder
     {
-        return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to');
+        return app()->make(PermissionApplicator::class)
+            ->restrictPageRelationQuery($query, 'images', 'uploaded_to')
+            ->whereIn('type', ['gallery', 'drawio']);
     }
 
     /**
diff --git a/app/Uploads/ImageResizer.php b/app/Uploads/ImageResizer.php
index 5f095658f..8d7571c82 100644
--- a/app/Uploads/ImageResizer.php
+++ b/app/Uploads/ImageResizer.php
@@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
 use Exception;
 use GuzzleHttp\Psr7\Utils;
 use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
 use Intervention\Image\Decoders\BinaryImageDecoder;
 use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder;
 use Intervention\Image\Drivers\Gd\Driver;
@@ -93,8 +94,8 @@ class ImageResizer
 
         $imageData = $disk->get($imagePath);
 
-        // Do not resize apng images where we're not cropping
-        if ($keepRatio && $this->isApngData($image, $imageData)) {
+        // Do not resize animated images where we're not cropping
+        if ($keepRatio && $this->isAnimated($image, $imageData)) {
             Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
 
             return $this->storage->getPublicUrl($image->path);
@@ -240,15 +241,50 @@ class ImageResizer
     /**
      * Check if the given image and image data is apng.
      */
-    protected function isApngData(Image $image, string &$imageData): bool
+    protected function isApngData(string &$imageData): bool
     {
-        $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
-        if (!$isPng) {
+        $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
+
+        return str_contains($initialHeader, 'acTL');
+    }
+
+    /**
+     * Check if the given avif image data represents an animated image.
+     * This is based up the answer here: https://stackoverflow.com/a/79457313
+     */
+    protected function isAnimatedAvifData(string &$imageData): bool
+    {
+        $stszPos = strpos($imageData, 'stsz');
+        if ($stszPos === false) {
             return false;
         }
 
-        $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
+        // Look 12 bytes after the start of 'stsz'
+        $start = $stszPos + 12;
+        $end = $start + 4;
+        if ($end > strlen($imageData) - 1) {
+            return false;
+        }
 
-        return str_contains($initialHeader, 'acTL');
+        $data = substr($imageData, $start, 4);
+        $count = unpack('Nvalue', $data)['value'];
+        return $count > 1;
+    }
+
+    /**
+     * Check if the given image is animated.
+     */
+    protected function isAnimated(Image $image, string &$imageData): bool
+    {
+        $extension = strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
+        if ($extension === 'png') {
+            return $this->isApngData($imageData);
+        }
+
+        if ($extension === 'avif') {
+            return $this->isAnimatedAvifData($imageData);
+        }
+
+        return false;
     }
 }
diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php
index 4d6d49197..ed640913e 100644
--- a/app/Uploads/ImageService.php
+++ b/app/Uploads/ImageService.php
@@ -13,7 +13,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
 
 class ImageService
 {
-    protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+    protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];
 
     public function __construct(
         protected ImageStorage $storage,
@@ -138,7 +138,7 @@ class ImageService
      * Get the raw data content from an image.
      *
      * @throws Exception
-     * @returns ?resource
+     * @return ?resource
      */
     public function getImageStream(Image $image): mixed
     {
@@ -148,7 +148,7 @@ class ImageService
     }
 
     /**
-     * Destroy an image along with its revisions, thumbnails and remaining folders.
+     * Destroy an image along with its revisions, thumbnails, and remaining folders.
      *
      * @throws Exception
      */
@@ -184,7 +184,7 @@ class ImageService
                 /** @var Image $image */
                 foreach ($images as $image) {
                     $searchQuery = '%' . basename($image->path) . '%';
-                    $inPage = DB::table('pages')
+                    $inPage = DB::table('entity_page_data')
                             ->where('html', 'like', $searchQuery)->count() > 0;
 
                     $inRevision = false;
@@ -252,16 +252,59 @@ class ImageService
     {
         $disk = $this->storage->getDisk('gallery');
 
+        return $disk->usingSecureImages() && $this->pathAccessible($imagePath);
+    }
+
+    /**
+     * Check if the given path exists and is accessible depending on the current settings.
+     */
+    public function pathAccessible(string $imagePath): bool
+    {
         if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
             return false;
         }
 
-        // Check local_secure is active
-        return $disk->usingSecureImages()
-            // Check the image file exists
-            && $disk->exists($imagePath)
-            // Check the file is likely an image file
-            && str_starts_with($disk->mimeType($imagePath), 'image/');
+        if ($this->blockedBySecureImages()) {
+            return false;
+        }
+
+        return $this->imageFileExists($imagePath, 'gallery');
+    }
+
+    /**
+     * Check if the given image should be accessible to the current user.
+     */
+    public function imageAccessible(Image $image): bool
+    {
+        if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImage($image)) {
+            return false;
+        }
+
+        if ($this->blockedBySecureImages()) {
+            return false;
+        }
+
+        return $this->imageFileExists($image->path, $image->type);
+    }
+
+    /**
+     * Check if the current user should be blocked from accessing images based on if secure images are enabled
+     * and if public access is enabled for the application.
+     */
+    protected function blockedBySecureImages(): bool
+    {
+        $enforced = $this->storage->usingSecureImages() && !setting('app-public');
+
+        return $enforced && user()->isGuest();
+    }
+
+    /**
+     * Check if the given image path exists for the given image type and that it is likely an image file.
+     */
+    protected function imageFileExists(string $imagePath, string $imageType): bool
+    {
+        $disk = $this->storage->getDisk($imageType);
+        return $disk->exists($imagePath) && str_starts_with($disk->mimeType($imagePath), 'image/');
     }
 
     /**
@@ -290,6 +333,11 @@ class ImageService
             return false;
         }
 
+        return $this->checkUserHasAccessToRelationOfImage($image);
+    }
+
+    protected function checkUserHasAccessToRelationOfImage(Image $image): bool
+    {
         $imageType = $image->type;
 
         // Allow user or system (logo) images
diff --git a/app/Uploads/ImageStorage.php b/app/Uploads/ImageStorage.php
index ddaa26a94..abf2b429b 100644
--- a/app/Uploads/ImageStorage.php
+++ b/app/Uploads/ImageStorage.php
@@ -34,6 +34,15 @@ class ImageStorage
         return config('filesystems.images') === 'local_secure_restricted';
     }
 
+    /**
+     * Check if "local secure" (Fetched behind auth, either with or without permissions enforced)
+     * is currently active in the instance.
+     */
+    public function usingSecureImages(): bool
+    {
+        return config('filesystems.images') === 'local_secure' || $this->usingSecureRestrictedImages();
+    }
+
     /**
      * Clean up an image file name to be both URL and storage safe.
      */
@@ -65,7 +74,7 @@ class ImageStorage
             return 'local';
         }
 
-        // Rename local_secure options to get our image specific storage driver which
+        // Rename local_secure options to get our image-specific storage driver, which
         // is scoped to the relevant image directories.
         if ($localSecureInUse) {
             return 'local_secure_images';
diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php
index 8e364831f..36d6721de 100644
--- a/app/Uploads/ImageStorageDisk.php
+++ b/app/Uploads/ImageStorageDisk.php
@@ -7,6 +7,7 @@ use Illuminate\Contracts\Filesystem\Filesystem;
 use Illuminate\Filesystem\FilesystemAdapter;
 use Illuminate\Support\Facades\Log;
 use League\Flysystem\UnableToSetVisibility;
+use League\Flysystem\Visibility;
 use Symfony\Component\HttpFoundation\StreamedResponse;
 
 class ImageStorageDisk
@@ -60,7 +61,7 @@ class ImageStorageDisk
 
     /**
      * Get a stream to the file at the given path.
-     * @returns ?resource
+     * @return ?resource
      */
     public function stream(string $path): mixed
     {
@@ -85,7 +86,7 @@ class ImageStorageDisk
         // require different ACLs for S3, and this provides us more logical control.
         if ($makePublic && !$this->isS3Like()) {
             try {
-                $this->filesystem->setVisibility($path, 'public');
+                $this->filesystem->setVisibility($path, Visibility::PUBLIC);
             } catch (UnableToSetVisibility $e) {
                 Log::warning("Unable to set visibility for image upload with relative path: {$path}");
             }
diff --git a/app/Uploads/UserAvatars.php b/app/Uploads/UserAvatars.php
index c62324735..0cc640f22 100644
--- a/app/Uploads/UserAvatars.php
+++ b/app/Uploads/UserAvatars.php
@@ -5,6 +5,7 @@ namespace BookStack\Uploads;
 use BookStack\Exceptions\HttpFetchException;
 use BookStack\Http\HttpRequestService;
 use BookStack\Users\Models\User;
+use BookStack\Util\WebSafeMimeSniffer;
 use Exception;
 use GuzzleHttp\Psr7\Request;
 use Illuminate\Support\Facades\Log;
@@ -53,6 +54,33 @@ class UserAvatars
         }
     }
 
+    /**
+     * Assign a new avatar image to the given user by fetching from a remote URL.
+     */
+    public function assignToUserFromUrl(User $user, string $avatarUrl): void
+    {
+        try {
+            $this->destroyAllForUser($user);
+            $imageData = $this->getAvatarImageData($avatarUrl);
+
+            $mime = (new WebSafeMimeSniffer())->sniff($imageData);
+            [$format, $type] = explode('/', $mime, 2);
+            if ($format !== 'image' || !ImageService::isExtensionSupported($type)) {
+                return;
+            }
+
+            $avatar = $this->createAvatarImageFromData($user, $imageData, $type);
+            $user->avatar()->associate($avatar);
+            $user->save();
+        } catch (Exception $e) {
+            Log::error('Failed to save user avatar image from URL', [
+                'exception' => $e->getMessage(),
+                'url'       => $avatarUrl,
+                'user_id'   => $user->id,
+            ]);
+        }
+    }
+
     /**
      * Destroy all user avatars uploaded to the given user.
      */
@@ -105,7 +133,7 @@ class UserAvatars
     }
 
     /**
-     * Gets an image from url and returns it as a string of image data.
+     * Get an image from a URL and return it as a string of image data.
      *
      * @throws HttpFetchException
      */
@@ -113,7 +141,19 @@ class UserAvatars
     {
         try {
             $client = $this->http->buildClient(5);
-            $response = $client->sendRequest(new Request('GET', $url));
+            $responseCount = 0;
+
+            do {
+                $response = $client->sendRequest(new Request('GET', $url));
+                $responseCount++;
+                $isRedirect = ($response->getStatusCode() === 301 || $response->getStatusCode() === 302);
+                $url = $response->getHeader('Location')[0] ?? '';
+            } while ($responseCount < 3 && $isRedirect && is_string($url) && str_starts_with($url, 'http'));
+
+            if ($responseCount === 3) {
+                throw new HttpFetchException("Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: {$url}");
+            }
+
             if ($response->getStatusCode() !== 200) {
                 throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
             }
diff --git a/app/Users/Controllers/RoleApiController.php b/app/Users/Controllers/RoleApiController.php
index 2e96602fa..93ecc549b 100644
--- a/app/Users/Controllers/RoleApiController.php
+++ b/app/Users/Controllers/RoleApiController.php
@@ -3,6 +3,7 @@
 namespace BookStack\Users\Controllers;
 
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use BookStack\Permissions\PermissionsRepo;
 use BookStack\Users\Models\Role;
 use Illuminate\Http\Request;
@@ -10,13 +11,11 @@ use Illuminate\Support\Facades\DB;
 
 class RoleApiController extends ApiController
 {
-    protected PermissionsRepo $permissionsRepo;
-
     protected array $fieldsToExpose = [
         'display_name', 'description', 'mfa_enforced', 'external_auth_id', 'created_at', 'updated_at',
     ];
 
-    protected $rules = [
+    protected array $rules = [
         'create' => [
             'display_name'  => ['required', 'string', 'min:3', 'max:180'],
             'description'   => ['string', 'max:180'],
@@ -35,13 +34,12 @@ class RoleApiController extends ApiController
         ]
     ];
 
-    public function __construct(PermissionsRepo $permissionsRepo)
-    {
-        $this->permissionsRepo = $permissionsRepo;
-
+    public function __construct(
+        protected PermissionsRepo $permissionsRepo
+    ) {
         // Checks for all endpoints in this controller
         $this->middleware(function ($request, $next) {
-            $this->checkPermission('user-roles-manage');
+            $this->checkPermission(Permission::UserRolesManage);
 
             return $next($request);
         });
@@ -125,9 +123,9 @@ class RoleApiController extends ApiController
     }
 
     /**
-     * Format the given role model for single-result display.
+     * Format the given role model for a single-result display.
      */
-    protected function singleFormatter(Role $role)
+    protected function singleFormatter(Role $role): void
     {
         $role->load('users:id,name,slug');
         $role->unsetRelation('permissions');
diff --git a/app/Users/Controllers/RoleController.php b/app/Users/Controllers/RoleController.php
index 0a7fdcc9b..549f6e0ac 100644
--- a/app/Users/Controllers/RoleController.php
+++ b/app/Users/Controllers/RoleController.php
@@ -4,6 +4,7 @@ namespace BookStack\Users\Controllers;
 
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Permissions\PermissionsRepo;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Queries\RolesAllPaginatedAndSorted;
@@ -23,7 +24,7 @@ class RoleController extends Controller
      */
     public function index(Request $request)
     {
-        $this->checkPermission('user-roles-manage');
+        $this->checkPermission(Permission::UserRolesManage);
 
         $listOptions = SimpleListOptions::fromRequest($request, 'roles')->withSortOptions([
             'display_name' => trans('common.sort_name'),
@@ -49,7 +50,7 @@ class RoleController extends Controller
      */
     public function create(Request $request)
     {
-        $this->checkPermission('user-roles-manage');
+        $this->checkPermission(Permission::UserRolesManage);
 
         /** @var ?Role $role */
         $role = null;
@@ -71,7 +72,7 @@ class RoleController extends Controller
      */
     public function store(Request $request)
     {
-        $this->checkPermission('user-roles-manage');
+        $this->checkPermission(Permission::UserRolesManage);
         $data = $this->validate($request, [
             'display_name' => ['required', 'min:3', 'max:180'],
             'description'  => ['max:180'],
@@ -92,7 +93,7 @@ class RoleController extends Controller
      */
     public function edit(string $id)
     {
-        $this->checkPermission('user-roles-manage');
+        $this->checkPermission(Permission::UserRolesManage);
         $role = $this->permissionsRepo->getRoleById($id);
 
         $this->setPageTitle(trans('settings.role_edit'));
@@ -105,7 +106,7 @@ class RoleController extends Controller
      */
     public function update(Request $request, string $id)
     {
-        $this->checkPermission('user-roles-manage');
+        $this->checkPermission(Permission::UserRolesManage);
         $data = $this->validate($request, [
             'display_name' => ['required', 'min:3', 'max:180'],
             'description'  => ['max:180'],
@@ -127,7 +128,7 @@ class RoleController extends Controller
      */
     public function showDelete(string $id)
     {
-        $this->checkPermission('user-roles-manage');
+        $this->checkPermission(Permission::UserRolesManage);
         $role = $this->permissionsRepo->getRoleById($id);
         $roles = $this->permissionsRepo->getAllRolesExcept($role);
         $blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]);
@@ -146,7 +147,7 @@ class RoleController extends Controller
      */
     public function delete(Request $request, string $id)
     {
-        $this->checkPermission('user-roles-manage');
+        $this->checkPermission(Permission::UserRolesManage);
 
         try {
             $migrateRoleId = intval($request->get('migrate_role_id') ?: "0");
diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php
index 708a91e9d..a8baba529 100644
--- a/app/Users/Controllers/UserAccountController.php
+++ b/app/Users/Controllers/UserAccountController.php
@@ -4,6 +4,7 @@ namespace BookStack\Users\Controllers;
 
 use BookStack\Access\SocialDriverManager;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Permissions\PermissionApplicator;
 use BookStack\Settings\UserNotificationPreferences;
 use BookStack\Settings\UserShortcutMap;
@@ -62,9 +63,9 @@ class UserAccountController extends Controller
             'profile_image'    => array_merge(['nullable'], $this->getImageValidationRules()),
         ]);
 
-        $this->userRepo->update($user, $validated, userCan('users-manage'));
+        $this->userRepo->update($user, $validated, userCan(Permission::UsersManage));
 
-        // Save profile image if in request
+        // Save the profile image if in request
         if ($request->hasFile('profile_image')) {
             $imageUpload = $request->file('profile_image');
             $imageRepo->destroyImage($user->avatar);
@@ -73,7 +74,7 @@ class UserAccountController extends Controller
             $user->save();
         }
 
-        // Delete the profile image if reset option is in request
+        // Delete the profile image if the reset option is in request
         if ($request->has('profile_image_reset')) {
             $imageRepo->destroyImage($user->avatar);
             $user->image_id = 0;
@@ -122,7 +123,7 @@ class UserAccountController extends Controller
      */
     public function showNotifications(PermissionApplicator $permissions)
     {
-        $this->checkPermission('receive-notifications');
+        $this->checkPermission(Permission::ReceiveNotifications);
 
         $preferences = (new UserNotificationPreferences(user()));
 
@@ -145,7 +146,7 @@ class UserAccountController extends Controller
     public function updateNotifications(Request $request)
     {
         $this->preventAccessInDemoMode();
-        $this->checkPermission('receive-notifications');
+        $this->checkPermission(Permission::ReceiveNotifications);
         $data = $this->validate($request, [
            'preferences' => ['required', 'array'],
            'preferences.*' => ['required', 'string'],
@@ -218,7 +219,7 @@ class UserAccountController extends Controller
         $this->preventAccessInDemoMode();
 
         $requestNewOwnerId = intval($request->get('new_owner_id')) ?: null;
-        $newOwnerId = userCan('users-manage') ? $requestNewOwnerId : null;
+        $newOwnerId = userCan(Permission::UsersManage) ? $requestNewOwnerId : null;
 
         $this->userRepo->destroy(user(), $newOwnerId);
 
diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php
index bb2570b31..25753280f 100644
--- a/app/Users/Controllers/UserApiController.php
+++ b/app/Users/Controllers/UserApiController.php
@@ -2,11 +2,12 @@
 
 namespace BookStack\Users\Controllers;
 
+use BookStack\Entities\EntityExistsRule;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Http\ApiController;
+use BookStack\Permissions\Permission;
 use BookStack\Users\Models\User;
 use BookStack\Users\UserRepo;
-use Closure;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\Rules\Password;
@@ -26,7 +27,7 @@ class UserApiController extends ApiController
 
         // Checks for all endpoints in this controller
         $this->middleware(function ($request, $next) {
-            $this->checkPermission('users-manage');
+            $this->checkPermission(Permission::UsersManage);
             $this->preventAccessInDemoMode();
 
             return $next($request);
@@ -81,7 +82,7 @@ class UserApiController extends ApiController
         return $this->apiListingResponse($users, [
             'id', 'name', 'slug', 'email', 'external_auth_id',
             'created_at', 'updated_at', 'last_activity_at',
-        ], [Closure::fromCallable([$this, 'listFormatter'])]);
+        ], [$this->listFormatter(...)]);
     }
 
     /**
@@ -125,7 +126,7 @@ class UserApiController extends ApiController
     {
         $data = $this->validate($request, $this->rules($id)['update']);
         $user = $this->userRepo->getById($id);
-        $this->userRepo->update($user, $data, userCan('users-manage'));
+        $this->userRepo->update($user, $data, userCan(Permission::UsersManage));
         $this->singleFormatter($user);
 
         return response()->json($user);
diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php
index c6e4326e9..494221b14 100644
--- a/app/Users/Controllers/UserController.php
+++ b/app/Users/Controllers/UserController.php
@@ -7,6 +7,7 @@ use BookStack\Access\UserInviteException;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Uploads\ImageRepo;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Queries\UsersAllPaginatedAndSorted;
@@ -32,7 +33,7 @@ class UserController extends Controller
      */
     public function index(Request $request)
     {
-        $this->checkPermission('users-manage');
+        $this->checkPermission(Permission::UsersManage);
 
         $listOptions = SimpleListOptions::fromRequest($request, 'users')->withSortOptions([
             'name' => trans('common.sort_name'),
@@ -58,7 +59,7 @@ class UserController extends Controller
      */
     public function create()
     {
-        $this->checkPermission('users-manage');
+        $this->checkPermission(Permission::UsersManage);
         $authMethod = config('auth.method');
         $roles = Role::query()->orderBy('display_name', 'asc')->get();
         $this->setPageTitle(trans('settings.users_add_new'));
@@ -73,7 +74,7 @@ class UserController extends Controller
      */
     public function store(Request $request)
     {
-        $this->checkPermission('users-manage');
+        $this->checkPermission(Permission::UsersManage);
 
         $authMethod = config('auth.method');
         $sendInvite = ($request->get('send_invite', 'false') === 'true');
@@ -111,7 +112,7 @@ class UserController extends Controller
      */
     public function edit(int $id, SocialDriverManager $socialDriverManager)
     {
-        $this->checkPermission('users-manage');
+        $this->checkPermission(Permission::UsersManage);
 
         $user = $this->userRepo->getById($id);
         $user->load(['apiTokens', 'mfaValues']);
@@ -141,7 +142,7 @@ class UserController extends Controller
     public function update(Request $request, int $id)
     {
         $this->preventAccessInDemoMode();
-        $this->checkPermission('users-manage');
+        $this->checkPermission(Permission::UsersManage);
 
         $validated = $this->validate($request, [
             'name'             => ['min:1', 'max:100'],
@@ -182,7 +183,7 @@ class UserController extends Controller
      */
     public function delete(int $id)
     {
-        $this->checkPermission('users-manage');
+        $this->checkPermission(Permission::UsersManage);
 
         $user = $this->userRepo->getById($id);
         $this->setPageTitle(trans('settings.users_delete_named', ['userName' => $user->name]));
@@ -198,7 +199,7 @@ class UserController extends Controller
     public function destroy(Request $request, int $id)
     {
         $this->preventAccessInDemoMode();
-        $this->checkPermission('users-manage');
+        $this->checkPermission(Permission::UsersManage);
 
         $user = $this->userRepo->getById($id);
         $newOwnerId = intval($request->get('new_owner_id')) ?: null;
diff --git a/app/Users/Controllers/UserSearchController.php b/app/Users/Controllers/UserSearchController.php
index b6f37bce0..a2543b7ee 100644
--- a/app/Users/Controllers/UserSearchController.php
+++ b/app/Users/Controllers/UserSearchController.php
@@ -3,6 +3,7 @@
 namespace BookStack\Users\Controllers;
 
 use BookStack\Http\Controller;
+use BookStack\Permissions\Permission;
 use BookStack\Users\Models\User;
 use Illuminate\Http\Request;
 
@@ -15,9 +16,9 @@ class UserSearchController extends Controller
     public function forSelect(Request $request)
     {
         $hasPermission = !user()->isGuest() && (
-            userCan('users-manage')
-                || userCan('restrictions-manage-own')
-                || userCan('restrictions-manage-all')
+            userCan(Permission::UsersManage)
+                || userCan(Permission::RestrictionsManageOwn)
+                || userCan(Permission::RestrictionsManageAll)
         );
 
         if (!$hasPermission) {
diff --git a/app/Users/Models/HasCreatorAndUpdater.php b/app/Users/Models/HasCreatorAndUpdater.php
index 5ff46bc3c..bb05de11a 100644
--- a/app/Users/Models/HasCreatorAndUpdater.php
+++ b/app/Users/Models/HasCreatorAndUpdater.php
@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
 /**
  * @property int $created_by
  * @property int $updated_by
+ * @property ?User $createdBy
+ * @property ?User $updatedBy
  */
 trait HasCreatorAndUpdater
 {
@@ -25,4 +27,9 @@ trait HasCreatorAndUpdater
     {
         return $this->belongsTo(User::class, 'updated_by');
     }
+
+    public function getOwnerFieldName(): string
+    {
+        return 'created_by';
+    }
 }
diff --git a/app/Users/Models/HasOwner.php b/app/Users/Models/HasOwner.php
deleted file mode 100644
index eb830ef6f..000000000
--- a/app/Users/Models/HasOwner.php
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace BookStack\Users\Models;
-
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
-
-/**
- * @property int $owned_by
- */
-trait HasOwner
-{
-    /**
-     * Relation for the user that owns this entity.
-     */
-    public function ownedBy(): BelongsTo
-    {
-        return $this->belongsTo(User::class, 'owned_by');
-    }
-}
diff --git a/app/Users/Models/OwnableInterface.php b/app/Users/Models/OwnableInterface.php
new file mode 100644
index 000000000..8f738487f
--- /dev/null
+++ b/app/Users/Models/OwnableInterface.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace BookStack\Users\Models;
+
+interface OwnableInterface
+{
+    public function getOwnerFieldName(): string;
+}
diff --git a/app/Users/Models/Role.php b/app/Users/Models/Role.php
index d1452e66b..92be22458 100644
--- a/app/Users/Models/Role.php
+++ b/app/Users/Models/Role.php
@@ -53,10 +53,12 @@ class Role extends Model implements Loggable
 
     /**
      * The RolePermissions that belong to the role.
+     * @return BelongsToMany<RolePermission, $this>
      */
     public function permissions(): BelongsToMany
     {
-        return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
+        return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id')
+            ->select(['id', 'name']);
     }
 
     /**
diff --git a/app/Users/Models/User.php b/app/Users/Models/User.php
index 3797e7cb0..50efdcdad 100644
--- a/app/Users/Models/User.php
+++ b/app/Users/Models/User.php
@@ -10,8 +10,8 @@ use BookStack\Activity\Models\Loggable;
 use BookStack\Activity\Models\Watch;
 use BookStack\Api\ApiToken;
 use BookStack\App\Model;
-use BookStack\App\Sluggable;
-use BookStack\Entities\Tools\SlugGenerator;
+use BookStack\App\SluggableInterface;
+use BookStack\Permissions\Permission;
 use BookStack\Translation\LocaleDefinition;
 use BookStack\Translation\LocaleManager;
 use BookStack\Uploads\Image;
@@ -30,8 +30,6 @@ use Illuminate\Notifications\Notifiable;
 use Illuminate\Support\Collection;
 
 /**
- * Class User.
- *
  * @property int        $id
  * @property string     $name
  * @property string     $slug
@@ -45,8 +43,9 @@ use Illuminate\Support\Collection;
  * @property string     $system_name
  * @property Collection $roles
  * @property Collection $mfaValues
+ * @property ?Image     $avatar
  */
-class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
+class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, SluggableInterface
 {
     use HasFactory;
     use Authenticatable;
@@ -63,7 +62,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     /**
      * The attributes that are mass assignable.
      *
-     * @var array
+     * @var list<string>
      */
     protected $fillable = ['name', 'email'];
 
@@ -72,7 +71,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     /**
      * The attributes excluded from the model's JSON form.
      *
-     * @var array
+     * @var list<string>
      */
     protected $hidden = [
         'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
@@ -117,14 +116,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     /**
      * The roles that belong to the user.
      *
-     * @return BelongsToMany
+     * @return BelongsToMany<Role, $this>
      */
-    public function roles()
+    public function roles(): BelongsToMany
     {
-        if ($this->id === 0) {
-            return;
-        }
-
         return $this->belongsToMany(Role::class);
     }
 
@@ -158,8 +153,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     /**
      * Check if the user has a particular permission.
      */
-    public function can(string $permissionName): bool
+    public function can(string|Permission $permission): bool
     {
+        $permissionName = is_string($permission) ? $permission : $permission->value;
         return $this->permissions()->contains($permissionName);
     }
 
@@ -183,9 +179,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     }
 
     /**
-     * Clear any cached permissions on this instance.
+     * Clear any cached permissions in this instance.
      */
-    public function clearPermissionCache()
+    public function clearPermissionCache(): void
     {
         $this->permissions = null;
     }
@@ -193,7 +189,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     /**
      * Attach a role to this user.
      */
-    public function attachRole(Role $role)
+    public function attachRole(Role $role): void
     {
         $this->roles()->attach($role->id);
         $this->unsetRelation('roles');
@@ -209,15 +205,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 
     /**
      * Check if the user has a social account,
-     * If a driver is passed it checks for that single account type.
-     *
-     * @param bool|string $socialDriver
-     *
-     * @return bool
+     * If a driver is passed, it checks for that single account type.
      */
-    public function hasSocialAccount($socialDriver = false)
+    public function hasSocialAccount(string $socialDriver = ''): bool
     {
-        if ($socialDriver === false) {
+        if (empty($socialDriver)) {
             return $this->socialAccounts()->count() > 0;
         }
 
@@ -365,14 +357,4 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     {
         return "({$this->id}) {$this->name}";
     }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function refreshSlug(): string
-    {
-        $this->slug = app()->make(SlugGenerator::class)->generate($this);
-
-        return $this->slug;
-    }
 }
diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php
index 5c8ace8fa..2c0897cef 100644
--- a/app/Users/UserRepo.php
+++ b/app/Users/UserRepo.php
@@ -5,13 +5,14 @@ namespace BookStack\Users;
 use BookStack\Access\UserInviteException;
 use BookStack\Access\UserInviteService;
 use BookStack\Activity\ActivityType;
-use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Facades\Activity;
 use BookStack\Uploads\UserAvatars;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
+use DB;
 use Exception;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Log;
@@ -21,11 +22,11 @@ class UserRepo
 {
     public function __construct(
         protected UserAvatars $userAvatar,
-        protected UserInviteService $inviteService
+        protected UserInviteService $inviteService,
+        protected SlugGenerator $slugGenerator,
     ) {
     }
 
-
     /**
      * Get a user by their email address.
      */
@@ -64,7 +65,7 @@ class UserRepo
         $user->email_confirmed = $emailConfirmed;
         $user->external_auth_id = $data['external_auth_id'] ?? '';
 
-        $user->refreshSlug();
+        $this->slugGenerator->regenerateForUser($user);
         $user->save();
 
         if (!empty($data['language'])) {
@@ -100,17 +101,17 @@ class UserRepo
     }
 
     /**
-     * Update the given user with the given data.
+     * Update the given user with the given data, but do not create an activity.
      *
      * @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
      *
      * @throws UserUpdateException
      */
-    public function update(User $user, array $data, bool $manageUsersAllowed): User
+    public function updateWithoutActivity(User $user, array $data, bool $manageUsersAllowed): User
     {
         if (!empty($data['name'])) {
             $user->name = $data['name'];
-            $user->refreshSlug();
+            $this->slugGenerator->regenerateForUser($user);
         }
 
         if (!empty($data['email']) && $manageUsersAllowed) {
@@ -134,6 +135,21 @@ class UserRepo
         }
 
         $user->save();
+
+        return $user;
+    }
+
+    /**
+     * Update the given user with the given data.
+     *
+     * @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
+     *
+     * @throws UserUpdateException
+     */
+    public function update(User $user, array $data, bool $manageUsersAllowed): User
+    {
+        $user = $this->updateWithoutActivity($user, $data, $manageUsersAllowed);
+
         Activity::add(ActivityType::USER_UPDATE, $user);
 
         return $user;
@@ -144,15 +160,12 @@ class UserRepo
      *
      * @throws Exception
      */
-    public function destroy(User $user, ?int $newOwnerId = null)
+    public function destroy(User $user, ?int $newOwnerId = null): void
     {
         $this->ensureDeletable($user);
 
-        $user->socialAccounts()->delete();
-        $user->apiTokens()->delete();
-        $user->favourites()->delete();
-        $user->mfaValues()->delete();
-        $user->watches()->delete();
+        $this->removeUserDependantRelations($user);
+        $this->nullifyUserNonDependantRelations($user);
         $user->delete();
 
         // Delete user profile images
@@ -161,16 +174,52 @@ class UserRepo
         // Delete related activities
         setting()->deleteUserSettings($user->id);
 
+        // Migrate or nullify ownership
+        $newOwner = null;
         if (!empty($newOwnerId)) {
             $newOwner = User::query()->find($newOwnerId);
-            if (!is_null($newOwner)) {
-                $this->migrateOwnership($user, $newOwner);
-            }
         }
+        $this->migrateOwnership($user, $newOwner);
 
         Activity::add(ActivityType::USER_DELETE, $user);
     }
 
+    protected function removeUserDependantRelations(User $user): void
+    {
+        $user->apiTokens()->delete();
+        $user->socialAccounts()->delete();
+        $user->favourites()->delete();
+        $user->mfaValues()->delete();
+        $user->watches()->delete();
+
+        $tables = ['email_confirmations', 'user_invites', 'views'];
+        foreach ($tables as $table) {
+            DB::table($table)->where('user_id', '=', $user->id)->delete();
+        }
+    }
+    protected function nullifyUserNonDependantRelations(User $user): void
+    {
+        $toNullify = [
+            'attachments' => ['created_by', 'updated_by'],
+            'comments' => ['created_by', 'updated_by'],
+            'deletions' => ['deleted_by'],
+            'entities' => ['created_by', 'updated_by'],
+            'images' => ['created_by', 'updated_by'],
+            'imports' => ['created_by'],
+            'joint_permissions' => ['owner_id'],
+            'page_revisions' => ['created_by'],
+            'sessions' => ['user_id'],
+        ];
+
+        foreach ($toNullify as $table => $columns) {
+            foreach ($columns as $column) {
+                DB::table($table)
+                    ->where($column, '=', $user->id)
+                    ->update([$column => null]);
+            }
+        }
+    }
+
     /**
      * @throws NotifyException
      */
@@ -188,13 +237,12 @@ class UserRepo
     /**
      * Migrate ownership of items in the system from one user to another.
      */
-    protected function migrateOwnership(User $fromUser, User $toUser)
+    protected function migrateOwnership(User $fromUser, User|null $toUser): void
     {
-        $entities = (new EntityProvider())->all();
-        foreach ($entities as $instance) {
-            $instance->newQuery()->where('owned_by', '=', $fromUser->id)
-                ->update(['owned_by' => $toUser->id]);
-        }
+        $newOwnerValue = $toUser ? $toUser->id : null;
+        DB::table('entities')
+            ->where('owned_by', '=', $fromUser->id)
+            ->update(['owned_by' => $newOwnerValue]);
     }
 
     /**
@@ -232,7 +280,7 @@ class UserRepo
      *
      * @throws UserUpdateException
      */
-    protected function setUserRoles(User $user, array $roles)
+    protected function setUserRoles(User $user, array $roles): void
     {
         $roles = array_filter(array_values($roles));
 
@@ -245,7 +293,7 @@ class UserRepo
 
     /**
      * Check if the given user is the last admin and their new roles no longer
-     * contains the admin role.
+     * contain the admin role.
      */
     protected function demotingLastAdmin(User $user, array $newRoles): bool
     {
diff --git a/app/Util/DatabaseTransaction.php b/app/Util/DatabaseTransaction.php
new file mode 100644
index 000000000..e36bd2ef3
--- /dev/null
+++ b/app/Util/DatabaseTransaction.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace BookStack\Util;
+
+use Closure;
+use Illuminate\Support\Facades\DB;
+use Throwable;
+
+/**
+ * Run the given code within a database transactions.
+ * Wraps Laravel's own transaction method, but sets a specific runtime isolation method.
+ * This sets a session level since this won't cause issues if already within a transaction,
+ * and this should apply to the next transactions anyway.
+ *
+ * "READ COMMITTED" ensures that changes from other transactions can be read within
+ * a transaction, even if started afterward (and for example, it was blocked by the initial
+ * transaction). This is quite important for things like permission generation, where we would
+ * want to consider the changes made by other committed transactions by the time we come to
+ * regenerate permission access.
+ *
+ * @throws Throwable
+ * @template TReturn of mixed
+ */
+class DatabaseTransaction
+{
+    /**
+     * @param  (Closure(static): TReturn)  $callback
+     */
+    public function __construct(
+        protected Closure $callback
+    ) {
+    }
+
+    /**
+     * @return TReturn
+     */
+    public function run(): mixed
+    {
+        DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED');
+        return DB::transaction($this->callback);
+    }
+}
diff --git a/app/Util/DateFormatter.php b/app/Util/DateFormatter.php
new file mode 100644
index 000000000..c6e60bd53
--- /dev/null
+++ b/app/Util/DateFormatter.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace BookStack\Util;
+
+use Carbon\Carbon;
+use Carbon\CarbonInterface;
+
+class DateFormatter
+{
+    public function __construct(
+        protected string $displayTimezone,
+    ) {
+    }
+
+    public function absolute(Carbon $date): string
+    {
+        $withDisplayTimezone = $date->clone()->setTimezone($this->displayTimezone);
+
+        return $withDisplayTimezone->format('Y-m-d H:i:s T');
+    }
+
+    public function relative(Carbon $date, bool $includeSuffix = true): string
+    {
+        return $date->diffForHumans(null, $includeSuffix ? null : CarbonInterface::DIFF_ABSOLUTE);
+    }
+}
diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php
index cb091b869..d4f7d2c8f 100644
--- a/app/Util/HtmlDescriptionFilter.php
+++ b/app/Util/HtmlDescriptionFilter.php
@@ -4,7 +4,6 @@ namespace BookStack\Util;
 
 use DOMAttr;
 use DOMElement;
-use DOMNamedNodeMap;
 use DOMNode;
 
 /**
@@ -25,6 +24,7 @@ class HtmlDescriptionFilter
         'ul' => [],
         'li' => [],
         'strong' => [],
+        'span' => [],
         'em' => [],
         'br' => [],
     ];
@@ -59,7 +59,6 @@ class HtmlDescriptionFilter
             return;
         }
 
-        /** @var DOMNamedNodeMap $attrs */
         $attrs = $element->attributes;
         for ($i = $attrs->length - 1; $i >= 0; $i--) {
             /** @var DOMAttr $attr */
@@ -70,7 +69,8 @@ class HtmlDescriptionFilter
             }
         }
 
-        foreach ($element->childNodes as $child) {
+        $childNodes = [...$element->childNodes];
+        foreach ($childNodes as $child) {
             if ($child instanceof DOMElement) {
                 static::filterElement($child);
             }
diff --git a/app/Util/OutOfMemoryHandler.php b/app/Util/OutOfMemoryHandler.php
index 88e9581f4..6632d22a5 100644
--- a/app/Util/OutOfMemoryHandler.php
+++ b/app/Util/OutOfMemoryHandler.php
@@ -42,7 +42,7 @@ class OutOfMemoryHandler
     }
 
     /**
-     * Forget the handler so no action is taken place on out of memory.
+     * Forget the handler, so no action is taken place on out of memory.
      */
     public function forget(): void
     {
@@ -53,6 +53,11 @@ class OutOfMemoryHandler
 
     protected function getHandler(): Handler
     {
+        /**
+         * We want to resolve our specific BookStack handling via the set app handler
+         * singleton, but phpstan will only infer based on the interface.
+         * @phpstan-ignore return.type
+         */
         return app()->make(ExceptionHandler::class);
     }
 }
diff --git a/bookstack-system-cli b/bookstack-system-cli
index 6283aa0cb..c55c5a8a7 100755
Binary files a/bookstack-system-cli and b/bookstack-system-cli differ
diff --git a/composer.json b/composer.json
index b77c90dd3..ab33e87a4 100644
--- a/composer.json
+++ b/composer.json
@@ -22,7 +22,7 @@
         "guzzlehttp/guzzle": "^7.4",
         "intervention/image": "^3.5",
         "knplabs/knp-snappy": "^1.5",
-        "laravel/framework": "^v11.37",
+        "laravel/framework": "^v12.26.4",
         "laravel/socialite": "^5.10",
         "laravel/tinker": "^2.8",
         "league/commonmark": "^2.3",
@@ -32,20 +32,19 @@
         "onelogin/php-saml": "^4.0",
         "phpseclib/phpseclib": "^3.0",
         "pragmarx/google2fa": "^8.0",
-        "predis/predis": "^2.1",
+        "predis/predis": "^3.2",
         "socialiteproviders/discord": "^4.1",
         "socialiteproviders/gitlab": "^4.1",
         "socialiteproviders/microsoft-azure": "^5.1",
         "socialiteproviders/okta": "^4.2",
         "socialiteproviders/twitch": "^5.3",
-        "ssddanbrown/htmldiff": "^1.0.2",
-        "ssddanbrown/symfony-mailer": "7.2.x-dev"
+        "ssddanbrown/htmldiff": "^2.0.0"
     },
     "require-dev": {
         "fakerphp/faker": "^1.21",
         "itsgoingd/clockwork": "^5.1",
         "mockery/mockery": "^1.5",
-        "nunomaduro/collision": "^8.1",
+        "nunomaduro/collision": "^8.6",
         "larastan/larastan": "^v3.0",
         "phpunit/phpunit": "^11.5",
         "squizlabs/php_codesniffer": "^3.7",
@@ -94,6 +93,7 @@
             "@php artisan view:clear"
         ],
         "refresh-test-database": [
+            "@putenv APP_TIMEZONE=UTC",
             "@php artisan migrate:refresh --database=mysql_testing",
             "@php artisan db:seed --class=DummyContentSeeder --database=mysql_testing"
         ]
diff --git a/composer.lock b/composer.lock
index 04dfaa501..881521a43 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "95a803e4d7797c8ab6c9803a6a3e99e5",
+    "content-hash": "506bcb9f80a5fd736b79c8e138efafa0",
     "packages": [
         {
             "name": "aws/aws-crt-php",
@@ -62,16 +62,16 @@
         },
         {
             "name": "aws/aws-sdk-php",
-            "version": "3.343.6",
+            "version": "3.362.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "3746aca8cbed5f46beba850e0a480ef58e71b197"
+                "reference": "f29a49b74d5ee771f13432e16d58651de91f7e79"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/3746aca8cbed5f46beba850e0a480ef58e71b197",
-                "reference": "3746aca8cbed5f46beba850e0a480ef58e71b197",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/f29a49b74d5ee771f13432e16d58651de91f7e79",
+                "reference": "f29a49b74d5ee771f13432e16d58651de91f7e79",
                 "shasum": ""
             },
             "require": {
@@ -84,7 +84,7 @@
                 "guzzlehttp/psr7": "^2.4.5",
                 "mtdowling/jmespath.php": "^2.8.0",
                 "php": ">=8.1",
-                "psr/http-message": "^2.0"
+                "psr/http-message": "^1.0 || ^2.0"
             },
             "require-dev": {
                 "andrewsville/php-token-reflection": "^1.4",
@@ -153,22 +153,22 @@
             "support": {
                 "forum": "https://github.com/aws/aws-sdk-php/discussions",
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.343.6"
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.362.1"
             },
-            "time": "2025-05-07T18:10:08+00:00"
+            "time": "2025-11-20T19:10:40+00:00"
         },
         {
             "name": "bacon/bacon-qr-code",
-            "version": "v3.0.1",
+            "version": "v3.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Bacon/BaconQrCode.git",
-                "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f"
+                "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f",
-                "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f",
+                "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563",
+                "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563",
                 "shasum": ""
             },
             "require": {
@@ -178,8 +178,9 @@
             },
             "require-dev": {
                 "phly/keep-a-changelog": "^2.12",
-                "phpunit/phpunit": "^10.5.11 || 11.0.4",
+                "phpunit/phpunit": "^10.5.11 || ^11.0.4",
                 "spatie/phpunit-snapshot-assertions": "^5.1.5",
+                "spatie/pixelmatch-php": "^1.2.0",
                 "squizlabs/php_codesniffer": "^3.9"
             },
             "suggest": {
@@ -207,31 +208,31 @@
             "homepage": "https://github.com/Bacon/BaconQrCode",
             "support": {
                 "issues": "https://github.com/Bacon/BaconQrCode/issues",
-                "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1"
+                "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3"
             },
-            "time": "2024-10-01T13:55:55+00:00"
+            "time": "2025-11-19T17:15:36+00:00"
         },
         {
             "name": "brick/math",
-            "version": "0.12.3",
+            "version": "0.14.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/brick/math.git",
-                "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba"
+                "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba",
-                "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba",
+                "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2",
+                "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2",
                 "shasum": ""
             },
             "require": {
-                "php": "^8.1"
+                "php": "^8.2"
             },
             "require-dev": {
                 "php-coveralls/php-coveralls": "^2.2",
-                "phpunit/phpunit": "^10.1",
-                "vimeo/psalm": "6.8.8"
+                "phpstan/phpstan": "2.1.22",
+                "phpunit/phpunit": "^11.5"
             },
             "type": "library",
             "autoload": {
@@ -261,7 +262,7 @@
             ],
             "support": {
                 "issues": "https://github.com/brick/math/issues",
-                "source": "https://github.com/brick/math/tree/0.12.3"
+                "source": "https://github.com/brick/math/tree/0.14.0"
             },
             "funding": [
                 {
@@ -269,7 +270,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-02-28T13:11:00+00:00"
+            "time": "2025-08-29T12:40:03+00:00"
         },
         {
             "name": "carbonphp/carbon-doctrine-types",
@@ -342,16 +343,16 @@
         },
         {
             "name": "dasprid/enum",
-            "version": "1.0.6",
+            "version": "1.0.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/DASPRiD/Enum.git",
-                "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90"
+                "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
-                "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
+                "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+                "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
                 "shasum": ""
             },
             "require": {
@@ -386,9 +387,9 @@
             ],
             "support": {
                 "issues": "https://github.com/DASPRiD/Enum/issues",
-                "source": "https://github.com/DASPRiD/Enum/tree/1.0.6"
+                "source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
             },
-            "time": "2024-08-09T14:30:48+00:00"
+            "time": "2025-09-16T12:23:56+00:00"
         },
         {
             "name": "dflydev/dot-access-data",
@@ -467,33 +468,32 @@
         },
         {
             "name": "doctrine/inflector",
-            "version": "2.0.10",
+            "version": "2.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/inflector.git",
-                "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc"
+                "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc",
-                "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc",
+                "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b",
+                "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.2 || ^8.0"
             },
             "require-dev": {
-                "doctrine/coding-standard": "^11.0",
-                "phpstan/phpstan": "^1.8",
-                "phpstan/phpstan-phpunit": "^1.1",
-                "phpstan/phpstan-strict-rules": "^1.3",
-                "phpunit/phpunit": "^8.5 || ^9.5",
-                "vimeo/psalm": "^4.25 || ^5.4"
+                "doctrine/coding-standard": "^12.0 || ^13.0",
+                "phpstan/phpstan": "^1.12 || ^2.0",
+                "phpstan/phpstan-phpunit": "^1.4 || ^2.0",
+                "phpstan/phpstan-strict-rules": "^1.6 || ^2.0",
+                "phpunit/phpunit": "^8.5 || ^12.2"
             },
             "type": "library",
             "autoload": {
                 "psr-4": {
-                    "Doctrine\\Inflector\\": "lib/Doctrine/Inflector"
+                    "Doctrine\\Inflector\\": "src"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -538,7 +538,7 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/inflector/issues",
-                "source": "https://github.com/doctrine/inflector/tree/2.0.10"
+                "source": "https://github.com/doctrine/inflector/tree/2.1.0"
             },
             "funding": [
                 {
@@ -554,7 +554,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-18T20:23:39+00:00"
+            "time": "2025-08-10T19:31:58+00:00"
         },
         {
             "name": "doctrine/lexer",
@@ -635,16 +635,16 @@
         },
         {
             "name": "dompdf/dompdf",
-            "version": "v3.1.0",
+            "version": "v3.1.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dompdf/dompdf.git",
-                "reference": "a51bd7a063a65499446919286fb18b518177155a"
+                "reference": "db712c90c5b9868df3600e64e68da62e78a34623"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dompdf/dompdf/zipball/a51bd7a063a65499446919286fb18b518177155a",
-                "reference": "a51bd7a063a65499446919286fb18b518177155a",
+                "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
+                "reference": "db712c90c5b9868df3600e64e68da62e78a34623",
                 "shasum": ""
             },
             "require": {
@@ -693,9 +693,9 @@
             "homepage": "https://github.com/dompdf/dompdf",
             "support": {
                 "issues": "https://github.com/dompdf/dompdf/issues",
-                "source": "https://github.com/dompdf/dompdf/tree/v3.1.0"
+                "source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
             },
-            "time": "2025-01-15T14:09:04+00:00"
+            "time": "2025-10-29T12:43:30+00:00"
         },
         {
             "name": "dompdf/php-font-lib",
@@ -790,29 +790,28 @@
         },
         {
             "name": "dragonmantank/cron-expression",
-            "version": "v3.4.0",
+            "version": "v3.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dragonmantank/cron-expression.git",
-                "reference": "8c784d071debd117328803d86b2097615b457500"
+                "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
-                "reference": "8c784d071debd117328803d86b2097615b457500",
+                "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013",
+                "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2|^8.0",
-                "webmozart/assert": "^1.0"
+                "php": "^8.2|^8.3|^8.4|^8.5"
             },
             "replace": {
                 "mtdowling/cron-expression": "^1.0"
             },
             "require-dev": {
-                "phpstan/extension-installer": "^1.0",
-                "phpstan/phpstan": "^1.0",
-                "phpunit/phpunit": "^7.0|^8.0|^9.0"
+                "phpstan/extension-installer": "^1.4.3",
+                "phpstan/phpstan": "^1.12.32|^2.1.31",
+                "phpunit/phpunit": "^8.5.48|^9.0"
             },
             "type": "library",
             "extra": {
@@ -843,7 +842,7 @@
             ],
             "support": {
                 "issues": "https://github.com/dragonmantank/cron-expression/issues",
-                "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
+                "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0"
             },
             "funding": [
                 {
@@ -851,7 +850,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-10-09T13:47:03+00:00"
+            "time": "2025-10-31T18:51:33+00:00"
         },
         {
             "name": "egulias/email-validator",
@@ -1118,22 +1117,22 @@
         },
         {
             "name": "guzzlehttp/guzzle",
-            "version": "7.9.3",
+            "version": "7.10.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77"
+                "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
-                "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+                "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "guzzlehttp/promises": "^1.5.3 || ^2.0.3",
-                "guzzlehttp/psr7": "^2.7.0",
+                "guzzlehttp/promises": "^2.3",
+                "guzzlehttp/psr7": "^2.8",
                 "php": "^7.2.5 || ^8.0",
                 "psr/http-client": "^1.0",
                 "symfony/deprecation-contracts": "^2.2 || ^3.0"
@@ -1224,7 +1223,7 @@
             ],
             "support": {
                 "issues": "https://github.com/guzzle/guzzle/issues",
-                "source": "https://github.com/guzzle/guzzle/tree/7.9.3"
+                "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
             },
             "funding": [
                 {
@@ -1240,20 +1239,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-03-27T13:37:11+00:00"
+            "time": "2025-08-23T22:36:01+00:00"
         },
         {
             "name": "guzzlehttp/promises",
-            "version": "2.2.0",
+            "version": "2.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/promises.git",
-                "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c"
+                "reference": "481557b130ef3790cf82b713667b43030dc9c957"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c",
-                "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c",
+                "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
+                "reference": "481557b130ef3790cf82b713667b43030dc9c957",
                 "shasum": ""
             },
             "require": {
@@ -1261,7 +1260,7 @@
             },
             "require-dev": {
                 "bamarni/composer-bin-plugin": "^1.8.2",
-                "phpunit/phpunit": "^8.5.39 || ^9.6.20"
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25"
             },
             "type": "library",
             "extra": {
@@ -1307,7 +1306,7 @@
             ],
             "support": {
                 "issues": "https://github.com/guzzle/promises/issues",
-                "source": "https://github.com/guzzle/promises/tree/2.2.0"
+                "source": "https://github.com/guzzle/promises/tree/2.3.0"
             },
             "funding": [
                 {
@@ -1323,20 +1322,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-03-27T13:27:01+00:00"
+            "time": "2025-08-22T14:34:08+00:00"
         },
         {
             "name": "guzzlehttp/psr7",
-            "version": "2.7.1",
+            "version": "2.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/psr7.git",
-                "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16"
+                "reference": "21dc724a0583619cd1652f673303492272778051"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16",
-                "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16",
+                "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
+                "reference": "21dc724a0583619cd1652f673303492272778051",
                 "shasum": ""
             },
             "require": {
@@ -1352,7 +1351,7 @@
             "require-dev": {
                 "bamarni/composer-bin-plugin": "^1.8.2",
                 "http-interop/http-factory-tests": "0.9.0",
-                "phpunit/phpunit": "^8.5.39 || ^9.6.20"
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25"
             },
             "suggest": {
                 "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -1423,7 +1422,7 @@
             ],
             "support": {
                 "issues": "https://github.com/guzzle/psr7/issues",
-                "source": "https://github.com/guzzle/psr7/tree/2.7.1"
+                "source": "https://github.com/guzzle/psr7/tree/2.8.0"
             },
             "funding": [
                 {
@@ -1439,20 +1438,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-03-27T12:30:47+00:00"
+            "time": "2025-08-23T21:21:41+00:00"
         },
         {
             "name": "guzzlehttp/uri-template",
-            "version": "v1.0.4",
+            "version": "v1.0.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/uri-template.git",
-                "reference": "30e286560c137526eccd4ce21b2de477ab0676d2"
+                "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2",
-                "reference": "30e286560c137526eccd4ce21b2de477ab0676d2",
+                "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1",
+                "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1",
                 "shasum": ""
             },
             "require": {
@@ -1461,7 +1460,7 @@
             },
             "require-dev": {
                 "bamarni/composer-bin-plugin": "^1.8.2",
-                "phpunit/phpunit": "^8.5.36 || ^9.6.15",
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25",
                 "uri-template/tests": "1.0.0"
             },
             "type": "library",
@@ -1509,7 +1508,7 @@
             ],
             "support": {
                 "issues": "https://github.com/guzzle/uri-template/issues",
-                "source": "https://github.com/guzzle/uri-template/tree/v1.0.4"
+                "source": "https://github.com/guzzle/uri-template/tree/v1.0.5"
             },
             "funding": [
                 {
@@ -1525,7 +1524,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-02-03T10:55:03+00:00"
+            "time": "2025-08-22T14:27:06+00:00"
         },
         {
             "name": "intervention/gif",
@@ -1597,16 +1596,16 @@
         },
         {
             "name": "intervention/image",
-            "version": "3.11.2",
+            "version": "3.11.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Intervention/image.git",
-                "reference": "ebbb711871fb261c064cf4c422f5f3c124fe1842"
+                "reference": "8c49eb21a6d2572532d1bc425964264f3e496846"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Intervention/image/zipball/ebbb711871fb261c064cf4c422f5f3c124fe1842",
-                "reference": "ebbb711871fb261c064cf4c422f5f3c124fe1842",
+                "url": "https://api.github.com/repos/Intervention/image/zipball/8c49eb21a6d2572532d1bc425964264f3e496846",
+                "reference": "8c49eb21a6d2572532d1bc425964264f3e496846",
                 "shasum": ""
             },
             "require": {
@@ -1653,7 +1652,7 @@
             ],
             "support": {
                 "issues": "https://github.com/Intervention/image/issues",
-                "source": "https://github.com/Intervention/image/tree/3.11.2"
+                "source": "https://github.com/Intervention/image/tree/3.11.4"
             },
             "funding": [
                 {
@@ -1669,7 +1668,7 @@
                     "type": "ko_fi"
                 }
             ],
-            "time": "2025-02-27T13:08:55+00:00"
+            "time": "2025-07-30T13:13:19+00:00"
         },
         {
             "name": "knplabs/knp-snappy",
@@ -1740,20 +1739,20 @@
         },
         {
             "name": "laravel/framework",
-            "version": "v11.44.7",
+            "version": "v12.39.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/framework.git",
-                "reference": "00bc6ac91a6d577bf051c18ddaa638c0d221e1c7"
+                "reference": "1a6176129ef28eaf42b6b4a6250025120c3d8dac"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/framework/zipball/00bc6ac91a6d577bf051c18ddaa638c0d221e1c7",
-                "reference": "00bc6ac91a6d577bf051c18ddaa638c0d221e1c7",
+                "url": "https://api.github.com/repos/laravel/framework/zipball/1a6176129ef28eaf42b6b4a6250025120c3d8dac",
+                "reference": "1a6176129ef28eaf42b6b4a6250025120c3d8dac",
                 "shasum": ""
             },
             "require": {
-                "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12",
+                "brick/math": "^0.11|^0.12|^0.13|^0.14",
                 "composer-runtime-api": "^2.2",
                 "doctrine/inflector": "^2.0.5",
                 "dragonmantank/cron-expression": "^3.4",
@@ -1768,32 +1767,34 @@
                 "fruitcake/php-cors": "^1.3",
                 "guzzlehttp/guzzle": "^7.8.2",
                 "guzzlehttp/uri-template": "^1.0",
-                "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0",
+                "laravel/prompts": "^0.3.0",
                 "laravel/serializable-closure": "^1.3|^2.0",
-                "league/commonmark": "^2.6",
+                "league/commonmark": "^2.7",
                 "league/flysystem": "^3.25.1",
                 "league/flysystem-local": "^3.25.1",
                 "league/uri": "^7.5.1",
                 "monolog/monolog": "^3.0",
-                "nesbot/carbon": "^2.72.6|^3.8.4",
+                "nesbot/carbon": "^3.8.4",
                 "nunomaduro/termwind": "^2.0",
                 "php": "^8.2",
                 "psr/container": "^1.1.1|^2.0.1",
                 "psr/log": "^1.0|^2.0|^3.0",
                 "psr/simple-cache": "^1.0|^2.0|^3.0",
                 "ramsey/uuid": "^4.7",
-                "symfony/console": "^7.0.3",
-                "symfony/error-handler": "^7.0.3",
-                "symfony/finder": "^7.0.3",
+                "symfony/console": "^7.2.0",
+                "symfony/error-handler": "^7.2.0",
+                "symfony/finder": "^7.2.0",
                 "symfony/http-foundation": "^7.2.0",
-                "symfony/http-kernel": "^7.0.3",
-                "symfony/mailer": "^7.0.3",
-                "symfony/mime": "^7.0.3",
-                "symfony/polyfill-php83": "^1.31",
-                "symfony/process": "^7.0.3",
-                "symfony/routing": "^7.0.3",
-                "symfony/uid": "^7.0.3",
-                "symfony/var-dumper": "^7.0.3",
+                "symfony/http-kernel": "^7.2.0",
+                "symfony/mailer": "^7.2.0",
+                "symfony/mime": "^7.2.0",
+                "symfony/polyfill-php83": "^1.33",
+                "symfony/polyfill-php84": "^1.33",
+                "symfony/polyfill-php85": "^1.33",
+                "symfony/process": "^7.2.0",
+                "symfony/routing": "^7.2.0",
+                "symfony/uid": "^7.2.0",
+                "symfony/var-dumper": "^7.2.0",
                 "tijsverkoyen/css-to-inline-styles": "^2.2.5",
                 "vlucas/phpdotenv": "^5.6.1",
                 "voku/portable-ascii": "^2.0.2"
@@ -1825,6 +1826,7 @@
                 "illuminate/filesystem": "self.version",
                 "illuminate/hashing": "self.version",
                 "illuminate/http": "self.version",
+                "illuminate/json-schema": "self.version",
                 "illuminate/log": "self.version",
                 "illuminate/macroable": "self.version",
                 "illuminate/mail": "self.version",
@@ -1857,17 +1859,18 @@
                 "league/flysystem-read-only": "^3.25.1",
                 "league/flysystem-sftp-v3": "^3.25.1",
                 "mockery/mockery": "^1.6.10",
-                "orchestra/testbench-core": "^9.11.2",
-                "pda/pheanstalk": "^5.0.6",
+                "opis/json-schema": "^2.4.1",
+                "orchestra/testbench-core": "^10.7.0",
+                "pda/pheanstalk": "^5.0.6|^7.0.0",
                 "php-http/discovery": "^1.15",
                 "phpstan/phpstan": "^2.0",
-                "phpunit/phpunit": "^10.5.35|^11.3.6|^12.0.1",
-                "predis/predis": "^2.3",
-                "resend/resend-php": "^0.10.0",
-                "symfony/cache": "^7.0.3",
-                "symfony/http-client": "^7.0.3",
-                "symfony/psr-http-message-bridge": "^7.0.3",
-                "symfony/translation": "^7.0.3"
+                "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1",
+                "predis/predis": "^2.3|^3.0",
+                "resend/resend-php": "^0.10.0|^1.0",
+                "symfony/cache": "^7.2.0",
+                "symfony/http-client": "^7.2.0",
+                "symfony/psr-http-message-bridge": "^7.2.0",
+                "symfony/translation": "^7.2.0"
             },
             "suggest": {
                 "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
@@ -1882,7 +1885,7 @@
                 "ext-pdo": "Required to use all database features.",
                 "ext-posix": "Required to use all features of the queue worker.",
                 "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).",
-                "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).",
+                "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).",
                 "filp/whoops": "Required for friendly error pages in development (^2.14.3).",
                 "laravel/tinker": "Required to use the tinker console command (^2.0).",
                 "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).",
@@ -1893,22 +1896,22 @@
                 "mockery/mockery": "Required to use mocking (^1.6).",
                 "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).",
                 "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).",
-                "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.3.6|^12.0.1).",
-                "predis/predis": "Required to use the predis connector (^2.3).",
+                "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).",
+                "predis/predis": "Required to use the predis connector (^2.3|^3.0).",
                 "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).",
                 "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).",
-                "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).",
-                "symfony/cache": "Required to PSR-6 cache bridge (^7.0).",
-                "symfony/filesystem": "Required to enable support for relative symbolic links (^7.0).",
-                "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.0).",
-                "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.0).",
-                "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.0).",
-                "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.0)."
+                "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).",
+                "symfony/cache": "Required to PSR-6 cache bridge (^7.2).",
+                "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).",
+                "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).",
+                "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).",
+                "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).",
+                "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)."
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "11.x-dev"
+                    "dev-master": "12.x-dev"
                 }
             },
             "autoload": {
@@ -1951,20 +1954,20 @@
                 "issues": "https://github.com/laravel/framework/issues",
                 "source": "https://github.com/laravel/framework"
             },
-            "time": "2025-04-25T12:40:47+00:00"
+            "time": "2025-11-18T15:16:10+00:00"
         },
         {
             "name": "laravel/prompts",
-            "version": "v0.3.5",
+            "version": "v0.3.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/prompts.git",
-                "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1"
+                "reference": "a1891d362714bc40c8d23b0b1d7090f022ea27cc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/prompts/zipball/57b8f7efe40333cdb925700891c7d7465325d3b1",
-                "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1",
+                "url": "https://api.github.com/repos/laravel/prompts/zipball/a1891d362714bc40c8d23b0b1d7090f022ea27cc",
+                "reference": "a1891d362714bc40c8d23b0b1d7090f022ea27cc",
                 "shasum": ""
             },
             "require": {
@@ -1981,8 +1984,8 @@
                 "illuminate/collections": "^10.0|^11.0|^12.0",
                 "mockery/mockery": "^1.5",
                 "pestphp/pest": "^2.3|^3.4",
-                "phpstan/phpstan": "^1.11",
-                "phpstan/phpstan-mockery": "^1.1"
+                "phpstan/phpstan": "^1.12.28",
+                "phpstan/phpstan-mockery": "^1.1.3"
             },
             "suggest": {
                 "ext-pcntl": "Required for the spinner to be animated."
@@ -2008,22 +2011,22 @@
             "description": "Add beautiful and user-friendly forms to your command-line applications.",
             "support": {
                 "issues": "https://github.com/laravel/prompts/issues",
-                "source": "https://github.com/laravel/prompts/tree/v0.3.5"
+                "source": "https://github.com/laravel/prompts/tree/v0.3.7"
             },
-            "time": "2025-02-11T13:34:40+00:00"
+            "time": "2025-09-19T13:47:56+00:00"
         },
         {
             "name": "laravel/serializable-closure",
-            "version": "v2.0.4",
+            "version": "v2.0.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/serializable-closure.git",
-                "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841"
+                "reference": "038ce42edee619599a1debb7e81d7b3759492819"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841",
-                "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841",
+                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/038ce42edee619599a1debb7e81d7b3759492819",
+                "reference": "038ce42edee619599a1debb7e81d7b3759492819",
                 "shasum": ""
             },
             "require": {
@@ -2071,20 +2074,20 @@
                 "issues": "https://github.com/laravel/serializable-closure/issues",
                 "source": "https://github.com/laravel/serializable-closure"
             },
-            "time": "2025-03-19T13:51:03+00:00"
+            "time": "2025-10-09T13:42:30+00:00"
         },
         {
             "name": "laravel/socialite",
-            "version": "v5.20.0",
+            "version": "v5.23.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/socialite.git",
-                "reference": "30972c12a41f71abeb418bc9ff157da8d9231519"
+                "reference": "83d7523c97c1101d288126948947891319eef800"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/socialite/zipball/30972c12a41f71abeb418bc9ff157da8d9231519",
-                "reference": "30972c12a41f71abeb418bc9ff157da8d9231519",
+                "url": "https://api.github.com/repos/laravel/socialite/zipball/83d7523c97c1101d288126948947891319eef800",
+                "reference": "83d7523c97c1101d288126948947891319eef800",
                 "shasum": ""
             },
             "require": {
@@ -2143,7 +2146,7 @@
                 "issues": "https://github.com/laravel/socialite/issues",
                 "source": "https://github.com/laravel/socialite"
             },
-            "time": "2025-04-21T14:21:34+00:00"
+            "time": "2025-10-27T15:36:41+00:00"
         },
         {
             "name": "laravel/tinker",
@@ -2213,16 +2216,16 @@
         },
         {
             "name": "league/commonmark",
-            "version": "2.7.0",
+            "version": "2.7.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/commonmark.git",
-                "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405"
+                "reference": "10732241927d3971d28e7ea7b5712721fa2296ca"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405",
-                "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405",
+                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca",
+                "reference": "10732241927d3971d28e7ea7b5712721fa2296ca",
                 "shasum": ""
             },
             "require": {
@@ -2251,7 +2254,7 @@
                 "symfony/process": "^5.4 | ^6.0 | ^7.0",
                 "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0",
                 "unleashedtech/php-coding-standard": "^3.1.1",
-                "vimeo/psalm": "^4.24.0 || ^5.0.0"
+                "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
             },
             "suggest": {
                 "symfony/yaml": "v2.3+ required if using the Front Matter extension"
@@ -2316,7 +2319,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-05-05T12:20:28+00:00"
+            "time": "2025-07-20T12:47:49+00:00"
         },
         {
             "name": "league/config",
@@ -2402,16 +2405,16 @@
         },
         {
             "name": "league/flysystem",
-            "version": "3.29.1",
+            "version": "3.30.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem.git",
-                "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319"
+                "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/edc1bb7c86fab0776c3287dbd19b5fa278347319",
-                "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319",
+                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277",
+                "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277",
                 "shasum": ""
             },
             "require": {
@@ -2435,13 +2438,13 @@
                 "composer/semver": "^3.0",
                 "ext-fileinfo": "*",
                 "ext-ftp": "*",
-                "ext-mongodb": "^1.3",
+                "ext-mongodb": "^1.3|^2",
                 "ext-zip": "*",
                 "friendsofphp/php-cs-fixer": "^3.5",
                 "google/cloud-storage": "^1.23",
                 "guzzlehttp/psr7": "^2.6",
                 "microsoft/azure-storage-blob": "^1.1",
-                "mongodb/mongodb": "^1.2",
+                "mongodb/mongodb": "^1.2|^2",
                 "phpseclib/phpseclib": "^3.0.36",
                 "phpstan/phpstan": "^1.10",
                 "phpunit/phpunit": "^9.5.11|^10.0",
@@ -2479,22 +2482,22 @@
             ],
             "support": {
                 "issues": "https://github.com/thephpleague/flysystem/issues",
-                "source": "https://github.com/thephpleague/flysystem/tree/3.29.1"
+                "source": "https://github.com/thephpleague/flysystem/tree/3.30.2"
             },
-            "time": "2024-10-08T08:58:34+00:00"
+            "time": "2025-11-10T17:13:11+00:00"
         },
         {
             "name": "league/flysystem-aws-s3-v3",
-            "version": "3.29.0",
+            "version": "3.30.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
-                "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9"
+                "reference": "d286e896083bed3190574b8b088b557b59eb66f5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/c6ff6d4606e48249b63f269eba7fabdb584e76a9",
-                "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9",
+                "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/d286e896083bed3190574b8b088b557b59eb66f5",
+                "reference": "d286e896083bed3190574b8b088b557b59eb66f5",
                 "shasum": ""
             },
             "require": {
@@ -2534,22 +2537,22 @@
                 "storage"
             ],
             "support": {
-                "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.29.0"
+                "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.30.1"
             },
-            "time": "2024-08-17T13:10:48+00:00"
+            "time": "2025-10-20T15:27:33+00:00"
         },
         {
             "name": "league/flysystem-local",
-            "version": "3.29.0",
+            "version": "3.30.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem-local.git",
-                "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27"
+                "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27",
-                "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27",
+                "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d",
+                "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d",
                 "shasum": ""
             },
             "require": {
@@ -2583,9 +2586,9 @@
                 "local"
             ],
             "support": {
-                "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0"
+                "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2"
             },
-            "time": "2024-08-09T21:24:39+00:00"
+            "time": "2025-11-10T11:23:37+00:00"
         },
         {
             "name": "league/html-to-markdown",
@@ -2875,33 +2878,38 @@
         },
         {
             "name": "league/uri",
-            "version": "7.5.1",
+            "version": "7.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/uri.git",
-                "reference": "81fb5145d2644324614cc532b28efd0215bda430"
+                "reference": "f625804987a0a9112d954f9209d91fec52182344"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430",
-                "reference": "81fb5145d2644324614cc532b28efd0215bda430",
+                "url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344",
+                "reference": "f625804987a0a9112d954f9209d91fec52182344",
                 "shasum": ""
             },
             "require": {
-                "league/uri-interfaces": "^7.5",
-                "php": "^8.1"
+                "league/uri-interfaces": "^7.6",
+                "php": "^8.1",
+                "psr/http-factory": "^1"
             },
             "conflict": {
                 "league/uri-schemes": "^1.0"
             },
             "suggest": {
                 "ext-bcmath": "to improve IPV4 host parsing",
+                "ext-dom": "to convert the URI into an HTML anchor tag",
                 "ext-fileinfo": "to create Data URI from file contennts",
                 "ext-gmp": "to improve IPV4 host parsing",
                 "ext-intl": "to handle IDN host with the best performance",
+                "ext-uri": "to use the PHP native URI class",
                 "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain",
                 "league/uri-components": "Needed to easily manipulate URI objects components",
+                "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP",
                 "php-64bit": "to improve IPV4 host parsing",
+                "rowbot/url": "to handle WHATWG URL",
                 "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
             },
             "type": "library",
@@ -2929,6 +2937,7 @@
             "description": "URI manipulation library",
             "homepage": "https://uri.thephpleague.com",
             "keywords": [
+                "URN",
                 "data-uri",
                 "file-uri",
                 "ftp",
@@ -2941,9 +2950,11 @@
                 "psr-7",
                 "query-string",
                 "querystring",
+                "rfc2141",
                 "rfc3986",
                 "rfc3987",
                 "rfc6570",
+                "rfc8141",
                 "uri",
                 "uri-template",
                 "url",
@@ -2953,7 +2964,7 @@
                 "docs": "https://uri.thephpleague.com",
                 "forum": "https://thephpleague.slack.com",
                 "issues": "https://github.com/thephpleague/uri-src/issues",
-                "source": "https://github.com/thephpleague/uri/tree/7.5.1"
+                "source": "https://github.com/thephpleague/uri/tree/7.6.0"
             },
             "funding": [
                 {
@@ -2961,26 +2972,25 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-12-08T08:40:02+00:00"
+            "time": "2025-11-18T12:17:23+00:00"
         },
         {
             "name": "league/uri-interfaces",
-            "version": "7.5.0",
+            "version": "7.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/uri-interfaces.git",
-                "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742"
+                "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
-                "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
+                "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368",
+                "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368",
                 "shasum": ""
             },
             "require": {
                 "ext-filter": "*",
                 "php": "^8.1",
-                "psr/http-factory": "^1",
                 "psr/http-message": "^1.1 || ^2.0"
             },
             "suggest": {
@@ -2988,6 +2998,7 @@
                 "ext-gmp": "to improve IPV4 host parsing",
                 "ext-intl": "to handle IDN host with the best performance",
                 "php-64bit": "to improve IPV4 host parsing",
+                "rowbot/url": "to handle WHATWG URL",
                 "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
             },
             "type": "library",
@@ -3012,7 +3023,7 @@
                     "homepage": "https://nyamsprod.com"
                 }
             ],
-            "description": "Common interfaces and classes for URI representation and interaction",
+            "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
             "homepage": "https://uri.thephpleague.com",
             "keywords": [
                 "data-uri",
@@ -3037,7 +3048,7 @@
                 "docs": "https://uri.thephpleague.com",
                 "forum": "https://thephpleague.slack.com",
                 "issues": "https://github.com/thephpleague/uri-src/issues",
-                "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0"
+                "source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0"
             },
             "funding": [
                 {
@@ -3045,20 +3056,20 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-12-08T08:18:47+00:00"
+            "time": "2025-11-18T12:17:23+00:00"
         },
         {
             "name": "masterminds/html5",
-            "version": "2.9.0",
+            "version": "2.10.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Masterminds/html5-php.git",
-                "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6"
+                "reference": "fcf91eb64359852f00d921887b219479b4f21251"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6",
-                "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6",
+                "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
+                "reference": "fcf91eb64359852f00d921887b219479b4f21251",
                 "shasum": ""
             },
             "require": {
@@ -3110,9 +3121,9 @@
             ],
             "support": {
                 "issues": "https://github.com/Masterminds/html5-php/issues",
-                "source": "https://github.com/Masterminds/html5-php/tree/2.9.0"
+                "source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
             },
-            "time": "2024-03-31T07:05:07+00:00"
+            "time": "2025-07-25T09:04:22+00:00"
         },
         {
             "name": "monolog/monolog",
@@ -3285,16 +3296,16 @@
         },
         {
             "name": "nesbot/carbon",
-            "version": "3.9.1",
+            "version": "3.10.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/CarbonPHP/carbon.git",
-                "reference": "ced71f79398ece168e24f7f7710462f462310d4d"
+                "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ced71f79398ece168e24f7f7710462f462310d4d",
-                "reference": "ced71f79398ece168e24f7f7710462f462310d4d",
+                "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f",
+                "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f",
                 "shasum": ""
             },
             "require": {
@@ -3302,9 +3313,9 @@
                 "ext-json": "*",
                 "php": "^8.1",
                 "psr/clock": "^1.0",
-                "symfony/clock": "^6.3 || ^7.0",
+                "symfony/clock": "^6.3.12 || ^7.0",
                 "symfony/polyfill-mbstring": "^1.0",
-                "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0"
+                "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0"
             },
             "provide": {
                 "psr/clock-implementation": "1.0"
@@ -3312,14 +3323,13 @@
             "require-dev": {
                 "doctrine/dbal": "^3.6.3 || ^4.0",
                 "doctrine/orm": "^2.15.2 || ^3.0",
-                "friendsofphp/php-cs-fixer": "^3.57.2",
+                "friendsofphp/php-cs-fixer": "^v3.87.1",
                 "kylekatarnls/multi-tester": "^2.5.3",
-                "ondrejmirtes/better-reflection": "^6.25.0.4",
                 "phpmd/phpmd": "^2.15.0",
-                "phpstan/extension-installer": "^1.3.1",
-                "phpstan/phpstan": "^1.11.2",
-                "phpunit/phpunit": "^10.5.20",
-                "squizlabs/php_codesniffer": "^3.9.0"
+                "phpstan/extension-installer": "^1.4.3",
+                "phpstan/phpstan": "^2.1.22",
+                "phpunit/phpunit": "^10.5.53",
+                "squizlabs/php_codesniffer": "^3.13.4"
             },
             "bin": [
                 "bin/carbon"
@@ -3387,29 +3397,29 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-05-01T19:51:51+00:00"
+            "time": "2025-09-06T13:39:36+00:00"
         },
         {
             "name": "nette/schema",
-            "version": "v1.3.2",
+            "version": "v1.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nette/schema.git",
-                "reference": "da801d52f0354f70a638673c4a0f04e16529431d"
+                "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d",
-                "reference": "da801d52f0354f70a638673c4a0f04e16529431d",
+                "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004",
+                "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004",
                 "shasum": ""
             },
             "require": {
                 "nette/utils": "^4.0",
-                "php": "8.1 - 8.4"
+                "php": "8.1 - 8.5"
             },
             "require-dev": {
                 "nette/tester": "^2.5.2",
-                "phpstan/phpstan-nette": "^1.0",
+                "phpstan/phpstan-nette": "^2.0@stable",
                 "tracy/tracy": "^2.8"
             },
             "type": "library",
@@ -3419,6 +3429,9 @@
                 }
             },
             "autoload": {
+                "psr-4": {
+                    "Nette\\": "src"
+                },
                 "classmap": [
                     "src/"
                 ]
@@ -3447,35 +3460,35 @@
             ],
             "support": {
                 "issues": "https://github.com/nette/schema/issues",
-                "source": "https://github.com/nette/schema/tree/v1.3.2"
+                "source": "https://github.com/nette/schema/tree/v1.3.3"
             },
-            "time": "2024-10-06T23:10:23+00:00"
+            "time": "2025-10-30T22:57:59+00:00"
         },
         {
             "name": "nette/utils",
-            "version": "v4.0.6",
+            "version": "v4.0.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nette/utils.git",
-                "reference": "ce708655043c7050eb050df361c5e313cf708309"
+                "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nette/utils/zipball/ce708655043c7050eb050df361c5e313cf708309",
-                "reference": "ce708655043c7050eb050df361c5e313cf708309",
+                "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede",
+                "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede",
                 "shasum": ""
             },
             "require": {
-                "php": "8.0 - 8.4"
+                "php": "8.0 - 8.5"
             },
             "conflict": {
                 "nette/finder": "<3",
                 "nette/schema": "<1.2.2"
             },
             "require-dev": {
-                "jetbrains/phpstorm-attributes": "dev-master",
+                "jetbrains/phpstorm-attributes": "^1.2",
                 "nette/tester": "^2.5",
-                "phpstan/phpstan": "^1.0",
+                "phpstan/phpstan-nette": "^2.0@stable",
                 "tracy/tracy": "^2.9"
             },
             "suggest": {
@@ -3493,6 +3506,9 @@
                 }
             },
             "autoload": {
+                "psr-4": {
+                    "Nette\\": "src"
+                },
                 "classmap": [
                     "src/"
                 ]
@@ -3533,22 +3549,22 @@
             ],
             "support": {
                 "issues": "https://github.com/nette/utils/issues",
-                "source": "https://github.com/nette/utils/tree/v4.0.6"
+                "source": "https://github.com/nette/utils/tree/v4.0.8"
             },
-            "time": "2025-03-30T21:06:30+00:00"
+            "time": "2025-08-06T21:43:34+00:00"
         },
         {
             "name": "nikic/php-parser",
-            "version": "v5.4.0",
+            "version": "v5.6.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "447a020a1f875a434d62f2a401f53b82a396e494"
+                "reference": "3a454ca033b9e06b63282ce19562e892747449bb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494",
-                "reference": "447a020a1f875a434d62f2a401f53b82a396e494",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb",
+                "reference": "3a454ca033b9e06b63282ce19562e892747449bb",
                 "shasum": ""
             },
             "require": {
@@ -3567,7 +3583,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "5.0-dev"
+                    "dev-master": "5.x-dev"
                 }
             },
             "autoload": {
@@ -3591,37 +3607,37 @@
             ],
             "support": {
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0"
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2"
             },
-            "time": "2024-12-30T11:07:19+00:00"
+            "time": "2025-10-21T19:32:17+00:00"
         },
         {
             "name": "nunomaduro/termwind",
-            "version": "v2.3.1",
+            "version": "v2.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nunomaduro/termwind.git",
-                "reference": "dfa08f390e509967a15c22493dc0bac5733d9123"
+                "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123",
-                "reference": "dfa08f390e509967a15c22493dc0bac5733d9123",
+                "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017",
+                "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017",
                 "shasum": ""
             },
             "require": {
                 "ext-mbstring": "*",
                 "php": "^8.2",
-                "symfony/console": "^7.2.6"
+                "symfony/console": "^7.3.6"
             },
             "require-dev": {
-                "illuminate/console": "^11.44.7",
-                "laravel/pint": "^1.22.0",
+                "illuminate/console": "^11.46.1",
+                "laravel/pint": "^1.25.1",
                 "mockery/mockery": "^1.6.12",
-                "pestphp/pest": "^2.36.0 || ^3.8.2",
-                "phpstan/phpstan": "^1.12.25",
+                "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3",
+                "phpstan/phpstan": "^1.12.32",
                 "phpstan/phpstan-strict-rules": "^1.6.2",
-                "symfony/var-dumper": "^7.2.6",
+                "symfony/var-dumper": "^7.3.5",
                 "thecodingmachine/phpstan-strict-rules": "^1.0.0"
             },
             "type": "library",
@@ -3664,7 +3680,7 @@
             ],
             "support": {
                 "issues": "https://github.com/nunomaduro/termwind/issues",
-                "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1"
+                "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3"
             },
             "funding": [
                 {
@@ -3680,20 +3696,20 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-05-08T08:14:37+00:00"
+            "time": "2025-11-20T02:34:59+00:00"
         },
         {
             "name": "onelogin/php-saml",
-            "version": "4.2.0",
+            "version": "4.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SAML-Toolkits/php-saml.git",
-                "reference": "d3b5172f137db2f412239432d77253ceaaa1e939"
+                "reference": "bf5efce9f2df5d489d05e78c27003a0fc8bc50f0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/d3b5172f137db2f412239432d77253ceaaa1e939",
-                "reference": "d3b5172f137db2f412239432d77253ceaaa1e939",
+                "url": "https://api.github.com/repos/SAML-Toolkits/php-saml/zipball/bf5efce9f2df5d489d05e78c27003a0fc8bc50f0",
+                "reference": "bf5efce9f2df5d489d05e78c27003a0fc8bc50f0",
                 "shasum": ""
             },
             "require": {
@@ -3744,28 +3760,30 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-05-30T15:10:40+00:00"
+            "time": "2025-05-25T14:28:00+00:00"
         },
         {
             "name": "paragonie/constant_time_encoding",
-            "version": "v3.0.0",
+            "version": "v3.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/paragonie/constant_time_encoding.git",
-                "reference": "df1e7fde177501eee2037dd159cf04f5f301a512"
+                "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512",
-                "reference": "df1e7fde177501eee2037dd159cf04f5f301a512",
+                "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+                "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
                 "shasum": ""
             },
             "require": {
                 "php": "^8"
             },
             "require-dev": {
-                "phpunit/phpunit": "^9",
-                "vimeo/psalm": "^4|^5"
+                "infection/infection": "^0",
+                "nikic/php-fuzzer": "^0",
+                "phpunit/phpunit": "^9|^10|^11",
+                "vimeo/psalm": "^4|^5|^6"
             },
             "type": "library",
             "autoload": {
@@ -3811,7 +3829,7 @@
                 "issues": "https://github.com/paragonie/constant_time_encoding/issues",
                 "source": "https://github.com/paragonie/constant_time_encoding"
             },
-            "time": "2024-05-08T12:36:18+00:00"
+            "time": "2025-09-24T15:06:41+00:00"
         },
         {
             "name": "paragonie/random_compat",
@@ -3865,16 +3883,16 @@
         },
         {
             "name": "phpoption/phpoption",
-            "version": "1.9.3",
+            "version": "1.9.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/schmittjoh/php-option.git",
-                "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54"
+                "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54",
-                "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54",
+                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
+                "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
                 "shasum": ""
             },
             "require": {
@@ -3882,7 +3900,7 @@
             },
             "require-dev": {
                 "bamarni/composer-bin-plugin": "^1.8.2",
-                "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34"
             },
             "type": "library",
             "extra": {
@@ -3924,7 +3942,7 @@
             ],
             "support": {
                 "issues": "https://github.com/schmittjoh/php-option/issues",
-                "source": "https://github.com/schmittjoh/php-option/tree/1.9.3"
+                "source": "https://github.com/schmittjoh/php-option/tree/1.9.4"
             },
             "funding": [
                 {
@@ -3936,20 +3954,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-07-20T21:41:07+00:00"
+            "time": "2025-08-21T11:53:16+00:00"
         },
         {
             "name": "phpseclib/phpseclib",
-            "version": "3.0.43",
+            "version": "3.0.47",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpseclib/phpseclib.git",
-                "reference": "709ec107af3cb2f385b9617be72af8cf62441d02"
+                "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02",
-                "reference": "709ec107af3cb2f385b9617be72af8cf62441d02",
+                "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/9d6ca36a6c2dd434765b1071b2644a1c683b385d",
+                "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d",
                 "shasum": ""
             },
             "require": {
@@ -4030,7 +4048,7 @@
             ],
             "support": {
                 "issues": "https://github.com/phpseclib/phpseclib/issues",
-                "source": "https://github.com/phpseclib/phpseclib/tree/3.0.43"
+                "source": "https://github.com/phpseclib/phpseclib/tree/3.0.47"
             },
             "funding": [
                 {
@@ -4046,7 +4064,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-12-14T21:12:59+00:00"
+            "time": "2025-10-06T01:07:24+00:00"
         },
         {
             "name": "pragmarx/google2fa",
@@ -4102,26 +4120,27 @@
         },
         {
             "name": "predis/predis",
-            "version": "v2.4.0",
+            "version": "v3.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/predis/predis.git",
-                "reference": "f49e13ee3a2a825631562aa0223ac922ec5d058b"
+                "reference": "9e9deec4dfd3ebf65d32eb368f498c646ba2ecd8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/predis/predis/zipball/f49e13ee3a2a825631562aa0223ac922ec5d058b",
-                "reference": "f49e13ee3a2a825631562aa0223ac922ec5d058b",
+                "url": "https://api.github.com/repos/predis/predis/zipball/9e9deec4dfd3ebf65d32eb368f498c646ba2ecd8",
+                "reference": "9e9deec4dfd3ebf65d32eb368f498c646ba2ecd8",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2 || ^8.0"
+                "php": "^7.2 || ^8.0",
+                "psr/http-message": "^1.0|^2.0"
             },
             "require-dev": {
                 "friendsofphp/php-cs-fixer": "^3.3",
                 "phpstan/phpstan": "^1.9",
                 "phpunit/phpcov": "^6.0 || ^8.0",
-                "phpunit/phpunit": "^8.0 || ^9.4"
+                "phpunit/phpunit": "^8.0 || ~9.4.4"
             },
             "suggest": {
                 "ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
@@ -4152,7 +4171,7 @@
             ],
             "support": {
                 "issues": "https://github.com/predis/predis/issues",
-                "source": "https://github.com/predis/predis/tree/v2.4.0"
+                "source": "https://github.com/predis/predis/tree/v3.2.0"
             },
             "funding": [
                 {
@@ -4160,7 +4179,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-04-30T15:16:02+00:00"
+            "time": "2025-08-06T06:41:24+00:00"
         },
         {
             "name": "psr/clock",
@@ -4576,16 +4595,16 @@
         },
         {
             "name": "psy/psysh",
-            "version": "v0.12.8",
+            "version": "v0.12.14",
             "source": {
                 "type": "git",
                 "url": "https://github.com/bobthecow/psysh.git",
-                "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625"
+                "reference": "95c29b3756a23855a30566b745d218bee690bef2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/bobthecow/psysh/zipball/85057ceedee50c49d4f6ecaff73ee96adb3b3625",
-                "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625",
+                "url": "https://api.github.com/repos/bobthecow/psysh/zipball/95c29b3756a23855a30566b745d218bee690bef2",
+                "reference": "95c29b3756a23855a30566b745d218bee690bef2",
                 "shasum": ""
             },
             "require": {
@@ -4600,11 +4619,12 @@
                 "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4"
             },
             "require-dev": {
-                "bamarni/composer-bin-plugin": "^1.2"
+                "bamarni/composer-bin-plugin": "^1.2",
+                "composer/class-map-generator": "^1.6"
             },
             "suggest": {
+                "composer/class-map-generator": "Improved tab completion performance with better class discovery.",
                 "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)",
-                "ext-pdo-sqlite": "The doc command requires SQLite to work.",
                 "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well."
             },
             "bin": [
@@ -4635,12 +4655,11 @@
             "authors": [
                 {
                     "name": "Justin Hileman",
-                    "email": "justin@justinhileman.info",
-                    "homepage": "http://justinhileman.com"
+                    "email": "justin@justinhileman.info"
                 }
             ],
             "description": "An interactive shell for modern PHP.",
-            "homepage": "http://psysh.org",
+            "homepage": "https://psysh.org",
             "keywords": [
                 "REPL",
                 "console",
@@ -4649,9 +4668,9 @@
             ],
             "support": {
                 "issues": "https://github.com/bobthecow/psysh/issues",
-                "source": "https://github.com/bobthecow/psysh/tree/v0.12.8"
+                "source": "https://github.com/bobthecow/psysh/tree/v0.12.14"
             },
-            "time": "2025-03-16T03:05:19+00:00"
+            "time": "2025-10-27T17:15:31+00:00"
         },
         {
             "name": "ralouphie/getallheaders",
@@ -4775,21 +4794,20 @@
         },
         {
             "name": "ramsey/uuid",
-            "version": "4.7.6",
+            "version": "4.9.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/ramsey/uuid.git",
-                "reference": "91039bc1faa45ba123c4328958e620d382ec7088"
+                "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088",
-                "reference": "91039bc1faa45ba123c4328958e620d382ec7088",
+                "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440",
+                "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440",
                 "shasum": ""
             },
             "require": {
-                "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12",
-                "ext-json": "*",
+                "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
                 "php": "^8.0",
                 "ramsey/collection": "^1.2 || ^2.0"
             },
@@ -4797,26 +4815,23 @@
                 "rhumsaa/uuid": "self.version"
             },
             "require-dev": {
-                "captainhook/captainhook": "^5.10",
+                "captainhook/captainhook": "^5.25",
                 "captainhook/plugin-composer": "^5.3",
-                "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
-                "doctrine/annotations": "^1.8",
-                "ergebnis/composer-normalize": "^2.15",
-                "mockery/mockery": "^1.3",
+                "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+                "ergebnis/composer-normalize": "^2.47",
+                "mockery/mockery": "^1.6",
                 "paragonie/random-lib": "^2",
-                "php-mock/php-mock": "^2.2",
-                "php-mock/php-mock-mockery": "^1.3",
-                "php-parallel-lint/php-parallel-lint": "^1.1",
-                "phpbench/phpbench": "^1.0",
-                "phpstan/extension-installer": "^1.1",
-                "phpstan/phpstan": "^1.8",
-                "phpstan/phpstan-mockery": "^1.1",
-                "phpstan/phpstan-phpunit": "^1.1",
-                "phpunit/phpunit": "^8.5 || ^9",
-                "ramsey/composer-repl": "^1.4",
-                "slevomat/coding-standard": "^8.4",
-                "squizlabs/php_codesniffer": "^3.5",
-                "vimeo/psalm": "^4.9"
+                "php-mock/php-mock": "^2.6",
+                "php-mock/php-mock-mockery": "^1.5",
+                "php-parallel-lint/php-parallel-lint": "^1.4.0",
+                "phpbench/phpbench": "^1.2.14",
+                "phpstan/extension-installer": "^1.4",
+                "phpstan/phpstan": "^2.1",
+                "phpstan/phpstan-mockery": "^2.0",
+                "phpstan/phpstan-phpunit": "^2.0",
+                "phpunit/phpunit": "^9.6",
+                "slevomat/coding-standard": "^8.18",
+                "squizlabs/php_codesniffer": "^3.13"
             },
             "suggest": {
                 "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.",
@@ -4851,19 +4866,9 @@
             ],
             "support": {
                 "issues": "https://github.com/ramsey/uuid/issues",
-                "source": "https://github.com/ramsey/uuid/tree/4.7.6"
+                "source": "https://github.com/ramsey/uuid/tree/4.9.1"
             },
-            "funding": [
-                {
-                    "url": "https://github.com/ramsey",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2024-04-27T21:32:50+00:00"
+            "time": "2025-09-04T20:59:21+00:00"
         },
         {
             "name": "robrichards/xmlseclibs",
@@ -4909,16 +4914,16 @@
         },
         {
             "name": "sabberworm/php-css-parser",
-            "version": "v8.8.0",
+            "version": "v8.9.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
-                "reference": "3de493bdddfd1f051249af725c7e0d2c38fed740"
+                "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/3de493bdddfd1f051249af725c7e0d2c38fed740",
-                "reference": "3de493bdddfd1f051249af725c7e0d2c38fed740",
+                "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9",
+                "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9",
                 "shasum": ""
             },
             "require": {
@@ -4926,7 +4931,8 @@
                 "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41"
+                "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41",
+                "rawr/cross-data-providers": "^2.0.0"
             },
             "suggest": {
                 "ext-mbstring": "for parsing UTF-8 CSS"
@@ -4968,9 +4974,9 @@
             ],
             "support": {
                 "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
-                "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.8.0"
+                "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0"
             },
-            "time": "2025-03-23T17:59:05+00:00"
+            "time": "2025-07-11T13:20:48+00:00"
         },
         {
             "name": "socialiteproviders/discord",
@@ -5290,22 +5296,23 @@
         },
         {
             "name": "ssddanbrown/htmldiff",
-            "version": "v1.0.4",
+            "version": "v2.0.0",
             "source": {
                 "type": "git",
                 "url": "https://codeberg.org/danb/HtmlDiff",
-                "reference": "d5cbd43f66c4e512cc0ab71d0e0b07271e7d6af6"
+                "reference": "b7bc848b01ae6e26a3e4ad4f07cd816e6faa8fe8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://codeberg.org/api/v1/repos/danb/HtmlDiff/archive/%prettyVersion%.zip"
+                "url": "https://codeberg.org/api/v1/repos/danb/HtmlDiff/archive/%prettyVersion%.zip",
+                "reference": "b7bc848b01ae6e26a3e4ad4f07cd816e6faa8fe8"
             },
             "require": {
                 "ext-mbstring": "*",
-                "php": ">=7.2"
+                "php": ">=8.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^8.5|^9.5.9",
+                "phpunit/phpunit": "^9.6.22",
                 "vimeo/psalm": "^4.10"
             },
             "type": "library",
@@ -5337,85 +5344,11 @@
                     "type": "kofi"
                 }
             ],
-            "time": "2024-12-12T16:45:37+00:00"
-        },
-        {
-            "name": "ssddanbrown/symfony-mailer",
-            "version": "7.2.x-dev",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/ssddanbrown/symfony-mailer.git",
-                "reference": "e9de8dccd76a63fc23475016e6574da6f5f12a2d"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/ssddanbrown/symfony-mailer/zipball/e9de8dccd76a63fc23475016e6574da6f5f12a2d",
-                "reference": "e9de8dccd76a63fc23475016e6574da6f5f12a2d",
-                "shasum": ""
-            },
-            "require": {
-                "egulias/email-validator": "^2.1.10|^3|^4",
-                "php": ">=8.2",
-                "psr/event-dispatcher": "^1",
-                "psr/log": "^1|^2|^3",
-                "symfony/event-dispatcher": "^6.4|^7.0",
-                "symfony/mime": "^7.2",
-                "symfony/service-contracts": "^2.5|^3"
-            },
-            "conflict": {
-                "symfony/http-client-contracts": "<2.5",
-                "symfony/http-kernel": "<6.4",
-                "symfony/messenger": "<6.4",
-                "symfony/mime": "<6.4",
-                "symfony/twig-bridge": "<6.4"
-            },
-            "replace": {
-                "symfony/mailer": "^7.0"
-            },
-            "require-dev": {
-                "symfony/console": "^6.4|^7.0",
-                "symfony/http-client": "^6.4|^7.0",
-                "symfony/messenger": "^6.4|^7.0",
-                "symfony/twig-bridge": "^6.4|^7.0"
-            },
-            "default-branch": true,
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Mailer\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Dan Brown",
-                    "homepage": "https://danb.me"
-                },
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Helps sending emails",
-            "homepage": "https://symfony.com",
-            "support": {
-                "source": "https://github.com/ssddanbrown/symfony-mailer/tree/7.2"
-            },
-            "time": "2025-01-11T14:57:07+00:00"
+            "time": "2025-07-07T11:55:59+00:00"
         },
         {
             "name": "symfony/clock",
-            "version": "v7.2.0",
+            "version": "v7.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/clock.git",
@@ -5469,7 +5402,7 @@
                 "time"
             ],
             "support": {
-                "source": "https://github.com/symfony/clock/tree/v7.2.0"
+                "source": "https://github.com/symfony/clock/tree/v7.3.0"
             },
             "funding": [
                 {
@@ -5489,23 +5422,24 @@
         },
         {
             "name": "symfony/console",
-            "version": "v7.2.6",
+            "version": "v7.3.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218"
+                "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/0e2e3f38c192e93e622e41ec37f4ca70cfedf218",
-                "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218",
+                "url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a",
+                "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a",
                 "shasum": ""
             },
             "require": {
                 "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3",
                 "symfony/polyfill-mbstring": "~1.0",
                 "symfony/service-contracts": "^2.5|^3",
-                "symfony/string": "^6.4|^7.0"
+                "symfony/string": "^7.2"
             },
             "conflict": {
                 "symfony/dependency-injection": "<6.4",
@@ -5562,7 +5496,7 @@
                 "terminal"
             ],
             "support": {
-                "source": "https://github.com/symfony/console/tree/v7.2.6"
+                "source": "https://github.com/symfony/console/tree/v7.3.6"
             },
             "funding": [
                 {
@@ -5573,25 +5507,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-04-07T19:09:28+00:00"
+            "time": "2025-11-04T01:21:42+00:00"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v7.2.0",
+            "version": "v7.3.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
-                "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2"
+                "reference": "84321188c4754e64273b46b406081ad9b18e8614"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2",
-                "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/84321188c4754e64273b46b406081ad9b18e8614",
+                "reference": "84321188c4754e64273b46b406081ad9b18e8614",
                 "shasum": ""
             },
             "require": {
@@ -5627,7 +5565,7 @@
             "description": "Converts CSS selectors to XPath expressions",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/css-selector/tree/v7.2.0"
+                "source": "https://github.com/symfony/css-selector/tree/v7.3.6"
             },
             "funding": [
                 {
@@ -5638,25 +5576,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-25T14:21:43+00:00"
+            "time": "2025-10-29T17:24:25+00:00"
         },
         {
             "name": "symfony/deprecation-contracts",
-            "version": "v3.5.1",
+            "version": "v3.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/deprecation-contracts.git",
-                "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
+                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
-                "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
                 "shasum": ""
             },
             "require": {
@@ -5669,7 +5611,7 @@
                     "name": "symfony/contracts"
                 },
                 "branch-alias": {
-                    "dev-main": "3.5-dev"
+                    "dev-main": "3.6-dev"
                 }
             },
             "autoload": {
@@ -5694,7 +5636,7 @@
             "description": "A generic function and convention to trigger deprecation notices",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
+                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
             },
             "funding": [
                 {
@@ -5710,20 +5652,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-25T14:20:29+00:00"
+            "time": "2024-09-25T14:21:43+00:00"
         },
         {
             "name": "symfony/error-handler",
-            "version": "v7.2.5",
+            "version": "v7.3.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/error-handler.git",
-                "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b"
+                "reference": "bbe40bfab84323d99dab491b716ff142410a92a8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/error-handler/zipball/102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b",
-                "reference": "102be5e6a8e4f4f3eb3149bcbfa33a80d1ee374b",
+                "url": "https://api.github.com/repos/symfony/error-handler/zipball/bbe40bfab84323d99dab491b716ff142410a92a8",
+                "reference": "bbe40bfab84323d99dab491b716ff142410a92a8",
                 "shasum": ""
             },
             "require": {
@@ -5736,9 +5678,11 @@
                 "symfony/http-kernel": "<6.4"
             },
             "require-dev": {
+                "symfony/console": "^6.4|^7.0",
                 "symfony/deprecation-contracts": "^2.5|^3",
                 "symfony/http-kernel": "^6.4|^7.0",
-                "symfony/serializer": "^6.4|^7.0"
+                "symfony/serializer": "^6.4|^7.0",
+                "symfony/webpack-encore-bundle": "^1.0|^2.0"
             },
             "bin": [
                 "Resources/bin/patch-type-declarations"
@@ -5769,7 +5713,7 @@
             "description": "Provides tools to manage errors and ease debugging PHP code",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/error-handler/tree/v7.2.5"
+                "source": "https://github.com/symfony/error-handler/tree/v7.3.6"
             },
             "funding": [
                 {
@@ -5780,25 +5724,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-03-03T07:12:39+00:00"
+            "time": "2025-10-31T19:12:50+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v7.2.0",
+            "version": "v7.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1"
+                "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1",
-                "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191",
+                "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191",
                 "shasum": ""
             },
             "require": {
@@ -5849,7 +5797,7 @@
             "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0"
+                "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3"
             },
             "funding": [
                 {
@@ -5860,25 +5808,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-25T14:21:43+00:00"
+            "time": "2025-08-13T11:49:31+00:00"
         },
         {
             "name": "symfony/event-dispatcher-contracts",
-            "version": "v3.5.1",
+            "version": "v3.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher-contracts.git",
-                "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f"
+                "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f",
-                "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
+                "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
                 "shasum": ""
             },
             "require": {
@@ -5892,7 +5844,7 @@
                     "name": "symfony/contracts"
                 },
                 "branch-alias": {
-                    "dev-main": "3.5-dev"
+                    "dev-main": "3.6-dev"
                 }
             },
             "autoload": {
@@ -5925,7 +5877,7 @@
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1"
+                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
             },
             "funding": [
                 {
@@ -5941,20 +5893,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-25T14:20:29+00:00"
+            "time": "2024-09-25T14:21:43+00:00"
         },
         {
             "name": "symfony/finder",
-            "version": "v7.2.2",
+            "version": "v7.3.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "87a71856f2f56e4100373e92529eed3171695cfb"
+                "reference": "9f696d2f1e340484b4683f7853b273abff94421f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb",
-                "reference": "87a71856f2f56e4100373e92529eed3171695cfb",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f",
+                "reference": "9f696d2f1e340484b4683f7853b273abff94421f",
                 "shasum": ""
             },
             "require": {
@@ -5989,7 +5941,7 @@
             "description": "Finds files and directories via an intuitive fluent interface",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/finder/tree/v7.2.2"
+                "source": "https://github.com/symfony/finder/tree/v7.3.5"
             },
             "funding": [
                 {
@@ -6000,25 +5952,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-12-30T19:00:17+00:00"
+            "time": "2025-10-15T18:45:57+00:00"
         },
         {
             "name": "symfony/http-foundation",
-            "version": "v7.2.6",
+            "version": "v7.3.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-foundation.git",
-                "reference": "6023ec7607254c87c5e69fb3558255aca440d72b"
+                "reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6023ec7607254c87c5e69fb3558255aca440d72b",
-                "reference": "6023ec7607254c87c5e69fb3558255aca440d72b",
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/db488a62f98f7a81d5746f05eea63a74e55bb7c4",
+                "reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4",
                 "shasum": ""
             },
             "require": {
@@ -6035,6 +5991,7 @@
                 "doctrine/dbal": "^3.6|^4",
                 "predis/predis": "^1.1|^2.0",
                 "symfony/cache": "^6.4.12|^7.1.5",
+                "symfony/clock": "^6.4|^7.0",
                 "symfony/dependency-injection": "^6.4|^7.0",
                 "symfony/expression-language": "^6.4|^7.0",
                 "symfony/http-kernel": "^6.4|^7.0",
@@ -6067,7 +6024,7 @@
             "description": "Defines an object-oriented layer for the HTTP specification",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-foundation/tree/v7.2.6"
+                "source": "https://github.com/symfony/http-foundation/tree/v7.3.7"
             },
             "funding": [
                 {
@@ -6078,25 +6035,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-04-09T08:14:01+00:00"
+            "time": "2025-11-08T16:41:12+00:00"
         },
         {
             "name": "symfony/http-kernel",
-            "version": "v7.2.6",
+            "version": "v7.3.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-kernel.git",
-                "reference": "f9dec01e6094a063e738f8945ef69c0cfcf792ec"
+                "reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f9dec01e6094a063e738f8945ef69c0cfcf792ec",
-                "reference": "f9dec01e6094a063e738f8945ef69c0cfcf792ec",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/10b8e9b748ea95fa4539c208e2487c435d3c87ce",
+                "reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce",
                 "shasum": ""
             },
             "require": {
@@ -6104,8 +6065,8 @@
                 "psr/log": "^1|^2|^3",
                 "symfony/deprecation-contracts": "^2.5|^3",
                 "symfony/error-handler": "^6.4|^7.0",
-                "symfony/event-dispatcher": "^6.4|^7.0",
-                "symfony/http-foundation": "^6.4|^7.0",
+                "symfony/event-dispatcher": "^7.3",
+                "symfony/http-foundation": "^7.3",
                 "symfony/polyfill-ctype": "^1.8"
             },
             "conflict": {
@@ -6181,7 +6142,91 @@
             "description": "Provides a structured process for converting a Request into a Response",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-kernel/tree/v7.2.6"
+                "source": "https://github.com/symfony/http-kernel/tree/v7.3.7"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-11-12T11:38:40+00:00"
+        },
+        {
+            "name": "symfony/mailer",
+            "version": "v7.3.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/mailer.git",
+                "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/mailer/zipball/fd497c45ba9c10c37864e19466b090dcb60a50ba",
+                "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba",
+                "shasum": ""
+            },
+            "require": {
+                "egulias/email-validator": "^2.1.10|^3|^4",
+                "php": ">=8.2",
+                "psr/event-dispatcher": "^1",
+                "psr/log": "^1|^2|^3",
+                "symfony/event-dispatcher": "^6.4|^7.0",
+                "symfony/mime": "^7.2",
+                "symfony/service-contracts": "^2.5|^3"
+            },
+            "conflict": {
+                "symfony/http-client-contracts": "<2.5",
+                "symfony/http-kernel": "<6.4",
+                "symfony/messenger": "<6.4",
+                "symfony/mime": "<6.4",
+                "symfony/twig-bridge": "<6.4"
+            },
+            "require-dev": {
+                "symfony/console": "^6.4|^7.0",
+                "symfony/http-client": "^6.4|^7.0",
+                "symfony/messenger": "^6.4|^7.0",
+                "symfony/twig-bridge": "^6.4|^7.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Mailer\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Helps sending emails",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/mailer/tree/v7.3.5"
             },
             "funding": [
                 {
@@ -6192,25 +6237,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-05-02T09:04:03+00:00"
+            "time": "2025-10-24T14:27:20+00:00"
         },
         {
             "name": "symfony/mime",
-            "version": "v7.2.6",
+            "version": "v7.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/mime.git",
-                "reference": "706e65c72d402539a072d0d6ad105fff6c161ef1"
+                "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/mime/zipball/706e65c72d402539a072d0d6ad105fff6c161ef1",
-                "reference": "706e65c72d402539a072d0d6ad105fff6c161ef1",
+                "url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35",
+                "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35",
                 "shasum": ""
             },
             "require": {
@@ -6265,7 +6314,7 @@
                 "mime-type"
             ],
             "support": {
-                "source": "https://github.com/symfony/mime/tree/v7.2.6"
+                "source": "https://github.com/symfony/mime/tree/v7.3.4"
             },
             "funding": [
                 {
@@ -6276,16 +6325,20 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-04-27T13:34:41+00:00"
+            "time": "2025-09-16T08:38:17+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.32.0",
+            "version": "v1.33.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
@@ -6344,7 +6397,7 @@
                 "portable"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0"
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
             },
             "funding": [
                 {
@@ -6355,6 +6408,10 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
@@ -6364,16 +6421,16 @@
         },
         {
             "name": "symfony/polyfill-intl-grapheme",
-            "version": "v1.32.0",
+            "version": "v1.33.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
-                "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe"
+                "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
-                "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
+                "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
                 "shasum": ""
             },
             "require": {
@@ -6422,7 +6479,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0"
+                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
             },
             "funding": [
                 {
@@ -6433,16 +6490,20 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-09T11:45:10+00:00"
+            "time": "2025-06-27T09:58:17+00:00"
         },
         {
             "name": "symfony/polyfill-intl-idn",
-            "version": "v1.32.0",
+            "version": "v1.33.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-intl-idn.git",
@@ -6505,7 +6566,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0"
+                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
             },
             "funding": [
                 {
@@ -6516,6 +6577,10 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
@@ -6525,7 +6590,7 @@
         },
         {
             "name": "symfony/polyfill-intl-normalizer",
-            "version": "v1.32.0",
+            "version": "v1.33.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@@ -6586,7 +6651,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0"
+                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
             },
             "funding": [
                 {
@@ -6597,6 +6662,10 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
@@ -6606,7 +6675,7 @@
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.32.0",
+            "version": "v1.33.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
@@ -6667,7 +6736,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0"
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
             },
             "funding": [
                 {
@@ -6678,6 +6747,10 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
@@ -6687,7 +6760,7 @@
         },
         {
             "name": "symfony/polyfill-php80",
-            "version": "v1.32.0",
+            "version": "v1.33.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php80.git",
@@ -6747,7 +6820,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0"
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
             },
             "funding": [
                 {
@@ -6758,6 +6831,10 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
@@ -6767,16 +6844,16 @@
         },
         {
             "name": "symfony/polyfill-php83",
-            "version": "v1.32.0",
+            "version": "v1.33.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php83.git",
-                "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491"
+                "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491",
-                "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491",
+                "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
+                "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
                 "shasum": ""
             },
             "require": {
@@ -6823,7 +6900,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0"
+                "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
             },
             "funding": [
                 {
@@ -6834,36 +6911,34 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-09T11:45:10+00:00"
+            "time": "2025-07-08T02:45:35+00:00"
         },
         {
-            "name": "symfony/polyfill-uuid",
-            "version": "v1.32.0",
+            "name": "symfony/polyfill-php84",
+            "version": "v1.33.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/polyfill-uuid.git",
-                "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2"
+                "url": "https://github.com/symfony/polyfill-php84.git",
+                "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
-                "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+                "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
+                "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.2"
             },
-            "provide": {
-                "ext-uuid": "*"
-            },
-            "suggest": {
-                "ext-uuid": "For best performance"
-            },
             "type": "library",
             "extra": {
                 "thanks": {
@@ -6876,8 +6951,11 @@
                     "bootstrap.php"
                 ],
                 "psr-4": {
-                    "Symfony\\Polyfill\\Uuid\\": ""
-                }
+                    "Symfony\\Polyfill\\Php84\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
@@ -6885,8 +6963,171 @@
             ],
             "authors": [
                 {
-                    "name": "Grégoire Pineau",
-                    "email": "lyrixx@lyrixx.info"
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-06-24T13:30:11+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php85",
+            "version": "v1.33.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php85.git",
+                "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
+                "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php85\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-06-23T16:12:55+00:00"
+        },
+        {
+            "name": "symfony/polyfill-uuid",
+            "version": "v1.33.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-uuid.git",
+                "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+                "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-uuid": "*"
+            },
+            "suggest": {
+                "ext-uuid": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Uuid\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Grégoire Pineau",
+                    "email": "lyrixx@lyrixx.info"
                 },
                 {
                     "name": "Symfony Community",
@@ -6902,7 +7143,7 @@
                 "uuid"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0"
+                "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0"
             },
             "funding": [
                 {
@@ -6913,6 +7154,10 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
@@ -6922,16 +7167,16 @@
         },
         {
             "name": "symfony/process",
-            "version": "v7.2.5",
+            "version": "v7.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "87b7c93e57df9d8e39a093d32587702380ff045d"
+                "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/87b7c93e57df9d8e39a093d32587702380ff045d",
-                "reference": "87b7c93e57df9d8e39a093d32587702380ff045d",
+                "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b",
+                "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b",
                 "shasum": ""
             },
             "require": {
@@ -6963,7 +7208,7 @@
             "description": "Executes commands in sub-processes",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/process/tree/v7.2.5"
+                "source": "https://github.com/symfony/process/tree/v7.3.4"
             },
             "funding": [
                 {
@@ -6974,25 +7219,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-03-13T12:21:46+00:00"
+            "time": "2025-09-11T10:12:26+00:00"
         },
         {
             "name": "symfony/routing",
-            "version": "v7.2.3",
+            "version": "v7.3.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/routing.git",
-                "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996"
+                "reference": "c97abe725f2a1a858deca629a6488c8fc20c3091"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/routing/zipball/ee9a67edc6baa33e5fae662f94f91fd262930996",
-                "reference": "ee9a67edc6baa33e5fae662f94f91fd262930996",
+                "url": "https://api.github.com/repos/symfony/routing/zipball/c97abe725f2a1a858deca629a6488c8fc20c3091",
+                "reference": "c97abe725f2a1a858deca629a6488c8fc20c3091",
                 "shasum": ""
             },
             "require": {
@@ -7044,7 +7293,7 @@
                 "url"
             ],
             "support": {
-                "source": "https://github.com/symfony/routing/tree/v7.2.3"
+                "source": "https://github.com/symfony/routing/tree/v7.3.6"
             },
             "funding": [
                 {
@@ -7055,25 +7304,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-01-17T10:56:55+00:00"
+            "time": "2025-11-05T07:57:47+00:00"
         },
         {
             "name": "symfony/service-contracts",
-            "version": "v3.5.1",
+            "version": "v3.6.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/service-contracts.git",
-                "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0"
+                "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0",
-                "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
+                "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
                 "shasum": ""
             },
             "require": {
@@ -7091,7 +7344,7 @@
                     "name": "symfony/contracts"
                 },
                 "branch-alias": {
-                    "dev-main": "3.5-dev"
+                    "dev-main": "3.6-dev"
                 }
             },
             "autoload": {
@@ -7127,7 +7380,7 @@
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/service-contracts/tree/v3.5.1"
+                "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
             },
             "funding": [
                 {
@@ -7138,25 +7391,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-25T14:20:29+00:00"
+            "time": "2025-07-15T11:30:57+00:00"
         },
         {
             "name": "symfony/string",
-            "version": "v7.2.6",
+            "version": "v7.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/string.git",
-                "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931"
+                "reference": "f96476035142921000338bad71e5247fbc138872"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931",
-                "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931",
+                "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872",
+                "reference": "f96476035142921000338bad71e5247fbc138872",
                 "shasum": ""
             },
             "require": {
@@ -7171,7 +7428,6 @@
             },
             "require-dev": {
                 "symfony/emoji": "^7.1",
-                "symfony/error-handler": "^6.4|^7.0",
                 "symfony/http-client": "^6.4|^7.0",
                 "symfony/intl": "^6.4|^7.0",
                 "symfony/translation-contracts": "^2.5|^3.0",
@@ -7214,7 +7470,7 @@
                 "utf8"
             ],
             "support": {
-                "source": "https://github.com/symfony/string/tree/v7.2.6"
+                "source": "https://github.com/symfony/string/tree/v7.3.4"
             },
             "funding": [
                 {
@@ -7225,25 +7481,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-04-20T20:18:16+00:00"
+            "time": "2025-09-11T14:36:48+00:00"
         },
         {
             "name": "symfony/translation",
-            "version": "v7.2.6",
+            "version": "v7.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6"
+                "reference": "ec25870502d0c7072d086e8ffba1420c85965174"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6",
-                "reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174",
+                "reference": "ec25870502d0c7072d086e8ffba1420c85965174",
                 "shasum": ""
             },
             "require": {
@@ -7253,6 +7513,7 @@
                 "symfony/translation-contracts": "^2.5|^3.0"
             },
             "conflict": {
+                "nikic/php-parser": "<5.0",
                 "symfony/config": "<6.4",
                 "symfony/console": "<6.4",
                 "symfony/dependency-injection": "<6.4",
@@ -7266,7 +7527,7 @@
                 "symfony/translation-implementation": "2.3|3.0"
             },
             "require-dev": {
-                "nikic/php-parser": "^4.18|^5.0",
+                "nikic/php-parser": "^5.0",
                 "psr/log": "^1|^2|^3",
                 "symfony/config": "^6.4|^7.0",
                 "symfony/console": "^6.4|^7.0",
@@ -7309,7 +7570,7 @@
             "description": "Provides tools to internationalize your application",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/translation/tree/v7.2.6"
+                "source": "https://github.com/symfony/translation/tree/v7.3.4"
             },
             "funding": [
                 {
@@ -7320,25 +7581,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-04-07T19:09:28+00:00"
+            "time": "2025-09-07T11:39:36+00:00"
         },
         {
             "name": "symfony/translation-contracts",
-            "version": "v3.5.1",
+            "version": "v3.6.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation-contracts.git",
-                "reference": "4667ff3bd513750603a09c8dedbea942487fb07c"
+                "reference": "65a8bc82080447fae78373aa10f8d13b38338977"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c",
-                "reference": "4667ff3bd513750603a09c8dedbea942487fb07c",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977",
+                "reference": "65a8bc82080447fae78373aa10f8d13b38338977",
                 "shasum": ""
             },
             "require": {
@@ -7351,7 +7616,7 @@
                     "name": "symfony/contracts"
                 },
                 "branch-alias": {
-                    "dev-main": "3.5-dev"
+                    "dev-main": "3.6-dev"
                 }
             },
             "autoload": {
@@ -7387,7 +7652,7 @@
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1"
+                "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1"
             },
             "funding": [
                 {
@@ -7398,25 +7663,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-25T14:20:29+00:00"
+            "time": "2025-07-15T13:41:35+00:00"
         },
         {
             "name": "symfony/uid",
-            "version": "v7.2.0",
+            "version": "v7.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/uid.git",
-                "reference": "2d294d0c48df244c71c105a169d0190bfb080426"
+                "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/uid/zipball/2d294d0c48df244c71c105a169d0190bfb080426",
-                "reference": "2d294d0c48df244c71c105a169d0190bfb080426",
+                "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb",
+                "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb",
                 "shasum": ""
             },
             "require": {
@@ -7461,7 +7730,7 @@
                 "uuid"
             ],
             "support": {
-                "source": "https://github.com/symfony/uid/tree/v7.2.0"
+                "source": "https://github.com/symfony/uid/tree/v7.3.1"
             },
             "funding": [
                 {
@@ -7477,31 +7746,31 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-25T14:21:43+00:00"
+            "time": "2025-06-27T19:55:54+00:00"
         },
         {
             "name": "symfony/var-dumper",
-            "version": "v7.2.6",
+            "version": "v7.3.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/var-dumper.git",
-                "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb"
+                "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9c46038cd4ed68952166cf7001b54eb539184ccb",
-                "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb",
+                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d",
+                "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d",
                 "shasum": ""
             },
             "require": {
                 "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3",
                 "symfony/polyfill-mbstring": "~1.0"
             },
             "conflict": {
                 "symfony/console": "<6.4"
             },
             "require-dev": {
-                "ext-iconv": "*",
                 "symfony/console": "^6.4|^7.0",
                 "symfony/http-kernel": "^6.4|^7.0",
                 "symfony/process": "^6.4|^7.0",
@@ -7544,7 +7813,7 @@
                 "dump"
             ],
             "support": {
-                "source": "https://github.com/symfony/var-dumper/tree/v7.2.6"
+                "source": "https://github.com/symfony/var-dumper/tree/v7.3.5"
             },
             "funding": [
                 {
@@ -7555,12 +7824,16 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-04-09T08:14:01+00:00"
+            "time": "2025-09-27T09:00:46+00:00"
         },
         {
             "name": "tijsverkoyen/css-to-inline-styles",
@@ -7774,64 +8047,6 @@
                 }
             ],
             "time": "2024-11-21T01:49:47+00:00"
-        },
-        {
-            "name": "webmozart/assert",
-            "version": "1.11.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/webmozarts/assert.git",
-                "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991",
-                "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991",
-                "shasum": ""
-            },
-            "require": {
-                "ext-ctype": "*",
-                "php": "^7.2 || ^8.0"
-            },
-            "conflict": {
-                "phpstan/phpstan": "<0.12.20",
-                "vimeo/psalm": "<4.6.1 || 4.6.2"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^8.5.13"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.10-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Webmozart\\Assert\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Bernhard Schussek",
-                    "email": "bschussek@gmail.com"
-                }
-            ],
-            "description": "Assertions to validate method input/output with nice error messages.",
-            "keywords": [
-                "assert",
-                "check",
-                "validate"
-            ],
-            "support": {
-                "issues": "https://github.com/webmozarts/assert/issues",
-                "source": "https://github.com/webmozarts/assert/tree/1.11.0"
-            },
-            "time": "2022-06-03T18:03:27+00:00"
         }
     ],
     "packages-dev": [
@@ -7900,16 +8115,16 @@
         },
         {
             "name": "filp/whoops",
-            "version": "2.18.0",
+            "version": "2.18.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/filp/whoops.git",
-                "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e"
+                "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/filp/whoops/zipball/a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
-                "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
+                "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+                "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
                 "shasum": ""
             },
             "require": {
@@ -7959,7 +8174,7 @@
             ],
             "support": {
                 "issues": "https://github.com/filp/whoops/issues",
-                "source": "https://github.com/filp/whoops/tree/2.18.0"
+                "source": "https://github.com/filp/whoops/tree/2.18.4"
             },
             "funding": [
                 {
@@ -7967,7 +8182,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-03-15T12:00:00+00:00"
+            "time": "2025-08-08T12:00:00+00:00"
         },
         {
             "name": "hamcrest/hamcrest-php",
@@ -8063,16 +8278,16 @@
         },
         {
             "name": "itsgoingd/clockwork",
-            "version": "v5.3.4",
+            "version": "v5.3.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/itsgoingd/clockwork.git",
-                "reference": "c27ad77a08a9e58bf0049de46969fa4fe3b506e5"
+                "reference": "d928483e231f042dbff9258795cb17aadaebc7d0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/c27ad77a08a9e58bf0049de46969fa4fe3b506e5",
-                "reference": "c27ad77a08a9e58bf0049de46969fa4fe3b506e5",
+                "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/d928483e231f042dbff9258795cb17aadaebc7d0",
+                "reference": "d928483e231f042dbff9258795cb17aadaebc7d0",
                 "shasum": ""
             },
             "require": {
@@ -8127,7 +8342,7 @@
             ],
             "support": {
                 "issues": "https://github.com/itsgoingd/clockwork/issues",
-                "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.4"
+                "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.5"
             },
             "funding": [
                 {
@@ -8135,20 +8350,20 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-02-09T15:57:21+00:00"
+            "time": "2025-09-14T15:34:49+00:00"
         },
         {
             "name": "larastan/larastan",
-            "version": "v3.4.0",
+            "version": "v3.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/larastan/larastan.git",
-                "reference": "1042fa0c2ee490bb6da7381f3323f7292ad68222"
+                "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/larastan/larastan/zipball/1042fa0c2ee490bb6da7381f3323f7292ad68222",
-                "reference": "1042fa0c2ee490bb6da7381f3323f7292ad68222",
+                "url": "https://api.github.com/repos/larastan/larastan/zipball/d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e",
+                "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e",
                 "shasum": ""
             },
             "require": {
@@ -8162,7 +8377,7 @@
                 "illuminate/pipeline": "^11.44.2 || ^12.4.1",
                 "illuminate/support": "^11.44.2 || ^12.4.1",
                 "php": "^8.2",
-                "phpstan/phpstan": "^2.1.11"
+                "phpstan/phpstan": "^2.1.29"
             },
             "require-dev": {
                 "doctrine/coding-standard": "^13",
@@ -8175,7 +8390,8 @@
                 "phpunit/phpunit": "^10.5.35 || ^11.5.15"
             },
             "suggest": {
-                "orchestra/testbench": "Using Larastan for analysing a package needs Testbench"
+                "orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
+                "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
             },
             "type": "phpstan-extension",
             "extra": {
@@ -8216,7 +8432,7 @@
             ],
             "support": {
                 "issues": "https://github.com/larastan/larastan/issues",
-                "source": "https://github.com/larastan/larastan/tree/v3.4.0"
+                "source": "https://github.com/larastan/larastan/tree/v3.8.0"
             },
             "funding": [
                 {
@@ -8224,7 +8440,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-04-22T09:44:59+00:00"
+            "time": "2025-10-27T23:09:14+00:00"
         },
         {
             "name": "mockery/mockery",
@@ -8311,16 +8527,16 @@
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.13.1",
+            "version": "1.13.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c"
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c",
-                "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
                 "shasum": ""
             },
             "require": {
@@ -8359,7 +8575,7 @@
             ],
             "support": {
                 "issues": "https://github.com/myclabs/DeepCopy/issues",
-                "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1"
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
             },
             "funding": [
                 {
@@ -8367,27 +8583,27 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-04-29T12:36:36+00:00"
+            "time": "2025-08-01T08:46:24+00:00"
         },
         {
             "name": "nunomaduro/collision",
-            "version": "v8.8.0",
+            "version": "v8.8.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nunomaduro/collision.git",
-                "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8"
+                "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nunomaduro/collision/zipball/4cf9f3b47afff38b139fb79ce54fc71799022ce8",
-                "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8",
+                "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4",
+                "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4",
                 "shasum": ""
             },
             "require": {
-                "filp/whoops": "^2.18.0",
-                "nunomaduro/termwind": "^2.3.0",
+                "filp/whoops": "^2.18.1",
+                "nunomaduro/termwind": "^2.3.1",
                 "php": "^8.2.0",
-                "symfony/console": "^7.2.5"
+                "symfony/console": "^7.3.0"
             },
             "conflict": {
                 "laravel/framework": "<11.44.2 || >=13.0.0",
@@ -8395,15 +8611,15 @@
             },
             "require-dev": {
                 "brianium/paratest": "^7.8.3",
-                "larastan/larastan": "^3.2",
-                "laravel/framework": "^11.44.2 || ^12.6",
-                "laravel/pint": "^1.21.2",
-                "laravel/sail": "^1.41.0",
-                "laravel/sanctum": "^4.0.8",
+                "larastan/larastan": "^3.4.2",
+                "laravel/framework": "^11.44.2 || ^12.18",
+                "laravel/pint": "^1.22.1",
+                "laravel/sail": "^1.43.1",
+                "laravel/sanctum": "^4.1.1",
                 "laravel/tinker": "^2.10.1",
-                "orchestra/testbench-core": "^9.12.0 || ^10.1",
-                "pestphp/pest": "^3.8.0",
-                "sebastian/environment": "^7.2.0 || ^8.0"
+                "orchestra/testbench-core": "^9.12.0 || ^10.4",
+                "pestphp/pest": "^3.8.2 || ^4.0.0",
+                "sebastian/environment": "^7.2.1 || ^8.0"
             },
             "type": "library",
             "extra": {
@@ -8466,7 +8682,7 @@
                     "type": "patreon"
                 }
             ],
-            "time": "2025-04-03T14:33:09+00:00"
+            "time": "2025-11-20T02:55:25+00:00"
         },
         {
             "name": "phar-io/manifest",
@@ -8588,16 +8804,11 @@
         },
         {
             "name": "phpstan/phpstan",
-            "version": "2.1.14",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2"
-            },
+            "version": "2.1.32",
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8f2e03099cac24ff3b379864d171c5acbfc6b9a2",
-                "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227",
+                "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227",
                 "shasum": ""
             },
             "require": {
@@ -8642,20 +8853,20 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-05-02T15:32:28+00:00"
+            "time": "2025-11-11T15:18:17+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
-            "version": "11.0.9",
+            "version": "11.0.11",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7"
+                "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/14d63fbcca18457e49c6f8bebaa91a87e8e188d7",
-                "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4",
+                "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4",
                 "shasum": ""
             },
             "require": {
@@ -8712,15 +8923,27 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
                 "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
-                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.9"
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2025-02-25T13:26:39+00:00"
+            "time": "2025-08-27T14:37:49+00:00"
         },
         {
             "name": "phpunit/php-file-iterator",
@@ -8969,16 +9192,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "11.5.19",
+            "version": "11.5.44",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5"
+                "reference": "c346885c95423eda3f65d85a194aaa24873cda82"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5",
-                "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c346885c95423eda3f65d85a194aaa24873cda82",
+                "reference": "c346885c95423eda3f65d85a194aaa24873cda82",
                 "shasum": ""
             },
             "require": {
@@ -8988,24 +9211,24 @@
                 "ext-mbstring": "*",
                 "ext-xml": "*",
                 "ext-xmlwriter": "*",
-                "myclabs/deep-copy": "^1.13.1",
+                "myclabs/deep-copy": "^1.13.4",
                 "phar-io/manifest": "^2.0.4",
                 "phar-io/version": "^3.2.1",
                 "php": ">=8.2",
-                "phpunit/php-code-coverage": "^11.0.9",
+                "phpunit/php-code-coverage": "^11.0.11",
                 "phpunit/php-file-iterator": "^5.1.0",
                 "phpunit/php-invoker": "^5.0.1",
                 "phpunit/php-text-template": "^4.0.1",
                 "phpunit/php-timer": "^7.0.1",
                 "sebastian/cli-parser": "^3.0.2",
                 "sebastian/code-unit": "^3.0.3",
-                "sebastian/comparator": "^6.3.1",
+                "sebastian/comparator": "^6.3.2",
                 "sebastian/diff": "^6.0.2",
-                "sebastian/environment": "^7.2.0",
-                "sebastian/exporter": "^6.3.0",
+                "sebastian/environment": "^7.2.1",
+                "sebastian/exporter": "^6.3.2",
                 "sebastian/global-state": "^7.0.2",
                 "sebastian/object-enumerator": "^6.0.1",
-                "sebastian/type": "^5.1.2",
+                "sebastian/type": "^5.1.3",
                 "sebastian/version": "^5.0.2",
                 "staabm/side-effects-detector": "^1.0.5"
             },
@@ -9050,7 +9273,7 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.19"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.44"
             },
             "funding": [
                 {
@@ -9074,7 +9297,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-05-02T06:56:52+00:00"
+            "time": "2025-11-13T07:17:35+00:00"
         },
         {
             "name": "sebastian/cli-parser",
@@ -9248,16 +9471,16 @@
         },
         {
             "name": "sebastian/comparator",
-            "version": "6.3.1",
+            "version": "6.3.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/comparator.git",
-                "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959"
+                "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/24b8fbc2c8e201bb1308e7b05148d6ab393b6959",
-                "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8",
+                "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8",
                 "shasum": ""
             },
             "require": {
@@ -9316,15 +9539,27 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/comparator/issues",
                 "security": "https://github.com/sebastianbergmann/comparator/security/policy",
-                "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.1"
+                "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2025-03-07T06:57:01+00:00"
+            "time": "2025-08-10T08:07:46+00:00"
         },
         {
             "name": "sebastian/complexity",
@@ -9453,23 +9688,23 @@
         },
         {
             "name": "sebastian/environment",
-            "version": "7.2.0",
+            "version": "7.2.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/environment.git",
-                "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5"
+                "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5",
-                "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4",
+                "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4",
                 "shasum": ""
             },
             "require": {
                 "php": ">=8.2"
             },
             "require-dev": {
-                "phpunit/phpunit": "^11.0"
+                "phpunit/phpunit": "^11.3"
             },
             "suggest": {
                 "ext-posix": "*"
@@ -9505,28 +9740,40 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/environment/issues",
                 "security": "https://github.com/sebastianbergmann/environment/security/policy",
-                "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0"
+                "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2024-07-03T04:54:44+00:00"
+            "time": "2025-05-21T11:55:47+00:00"
         },
         {
             "name": "sebastian/exporter",
-            "version": "6.3.0",
+            "version": "6.3.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/exporter.git",
-                "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3"
+                "reference": "70a298763b40b213ec087c51c739efcaa90bcd74"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3",
-                "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74",
+                "reference": "70a298763b40b213ec087c51c739efcaa90bcd74",
                 "shasum": ""
             },
             "require": {
@@ -9540,7 +9787,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "6.1-dev"
+                    "dev-main": "6.3-dev"
                 }
             },
             "autoload": {
@@ -9583,15 +9830,27 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/exporter/issues",
                 "security": "https://github.com/sebastianbergmann/exporter/security/policy",
-                "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0"
+                "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2024-12-05T09:17:50+00:00"
+            "time": "2025-09-24T06:12:51+00:00"
         },
         {
             "name": "sebastian/global-state",
@@ -9829,23 +10088,23 @@
         },
         {
             "name": "sebastian/recursion-context",
-            "version": "6.0.2",
+            "version": "6.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/recursion-context.git",
-                "reference": "694d156164372abbd149a4b85ccda2e4670c0e16"
+                "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16",
-                "reference": "694d156164372abbd149a4b85ccda2e4670c0e16",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc",
+                "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc",
                 "shasum": ""
             },
             "require": {
                 "php": ">=8.2"
             },
             "require-dev": {
-                "phpunit/phpunit": "^11.0"
+                "phpunit/phpunit": "^11.3"
             },
             "type": "library",
             "extra": {
@@ -9881,28 +10140,40 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
                 "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
-                "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2"
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2024-07-03T05:10:34+00:00"
+            "time": "2025-08-13T04:42:22+00:00"
         },
         {
             "name": "sebastian/type",
-            "version": "5.1.2",
+            "version": "5.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/type.git",
-                "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e"
+                "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/a8a7e30534b0eb0c77cd9d07e82de1a114389f5e",
-                "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
+                "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
                 "shasum": ""
             },
             "require": {
@@ -9938,15 +10209,27 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/type/issues",
                 "security": "https://github.com/sebastianbergmann/type/security/policy",
-                "source": "https://github.com/sebastianbergmann/type/tree/5.1.2"
+                "source": "https://github.com/sebastianbergmann/type/tree/5.1.3"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2025-03-18T13:35:50+00:00"
+            "time": "2025-08-09T06:55:48+00:00"
         },
         {
             "name": "sebastian/version",
@@ -10004,16 +10287,16 @@
         },
         {
             "name": "squizlabs/php_codesniffer",
-            "version": "3.12.2",
+            "version": "3.13.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
-                "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa"
+                "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa",
-                "reference": "6d4cf6032d4b718f168c90a96e36c7d0eaacb2aa",
+                "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
+                "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
                 "shasum": ""
             },
             "require": {
@@ -10030,11 +10313,6 @@
                 "bin/phpcs"
             ],
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.x-dev"
-                }
-            },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
                 "BSD-3-Clause"
@@ -10084,7 +10362,7 @@
                     "type": "thanks_dev"
                 }
             ],
-            "time": "2025-04-13T04:10:18+00:00"
+            "time": "2025-11-04T16:30:35+00:00"
         },
         {
             "name": "ssddanbrown/asserthtml",
@@ -10184,16 +10462,16 @@
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v7.2.4",
+            "version": "v7.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "19cc7b08efe9ad1ab1b56e0948e8d02e15ed3ef7"
+                "reference": "efa076ea0eeff504383ff0dcf827ea5ce15690ba"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/19cc7b08efe9ad1ab1b56e0948e8d02e15ed3ef7",
-                "reference": "19cc7b08efe9ad1ab1b56e0948e8d02e15ed3ef7",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/efa076ea0eeff504383ff0dcf827ea5ce15690ba",
+                "reference": "efa076ea0eeff504383ff0dcf827ea5ce15690ba",
                 "shasum": ""
             },
             "require": {
@@ -10231,7 +10509,7 @@
             "description": "Eases DOM navigation for HTML and XML documents",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dom-crawler/tree/v7.2.4"
+                "source": "https://github.com/symfony/dom-crawler/tree/v7.3.3"
             },
             "funding": [
                 {
@@ -10242,25 +10520,29 @@
                     "url": "https://github.com/fabpot",
                     "type": "github"
                 },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-02-17T15:53:07+00:00"
+            "time": "2025-08-06T20:13:54+00:00"
         },
         {
             "name": "theseer/tokenizer",
-            "version": "1.2.3",
+            "version": "1.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/theseer/tokenizer.git",
-                "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
-                "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
                 "shasum": ""
             },
             "require": {
@@ -10289,7 +10571,7 @@
             "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
             "support": {
                 "issues": "https://github.com/theseer/tokenizer/issues",
-                "source": "https://github.com/theseer/tokenizer/tree/1.2.3"
+                "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
             },
             "funding": [
                 {
@@ -10297,14 +10579,12 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-03-03T12:36:25+00:00"
+            "time": "2025-11-17T20:03:58+00:00"
         }
     ],
     "aliases": [],
     "minimum-stability": "stable",
-    "stability-flags": {
-        "ssddanbrown/symfony-mailer": 20
-    },
+    "stability-flags": {},
     "prefer-stable": true,
     "prefer-lowest": false,
     "platform": {
diff --git a/database/factories/Access/Mfa/MfaValueFactory.php b/database/factories/Access/Mfa/MfaValueFactory.php
new file mode 100644
index 000000000..03036b608
--- /dev/null
+++ b/database/factories/Access/Mfa/MfaValueFactory.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Database\Factories\Access\Mfa;
+
+use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Access\Mfa\MfaValue>
+ */
+class MfaValueFactory extends Factory
+{
+    protected $model = \BookStack\Access\Mfa\MfaValue::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'user_id' => User::factory(),
+            'method' => 'totp',
+            'value' => '123456',
+        ];
+    }
+}
diff --git a/database/factories/Access/SocialAccountFactory.php b/database/factories/Access/SocialAccountFactory.php
new file mode 100644
index 000000000..814f47b58
--- /dev/null
+++ b/database/factories/Access/SocialAccountFactory.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Database\Factories\Access;
+
+use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Access\SocialAccount>
+ */
+class SocialAccountFactory extends Factory
+{
+    protected $model = \BookStack\Access\SocialAccount::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'user_id' => User::factory(),
+            'driver' => 'github',
+            'driver_id' => '123456',
+            'avatar' => '',
+        ];
+    }
+}
diff --git a/database/factories/Activity/Models/ActivityFactory.php b/database/factories/Activity/Models/ActivityFactory.php
new file mode 100644
index 000000000..06ec07ced
--- /dev/null
+++ b/database/factories/Activity/Models/ActivityFactory.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Database\Factories\Activity\Models;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Activity\Models\Activity>
+ */
+class ActivityFactory extends Factory
+{
+    protected $model = \BookStack\Activity\Models\Activity::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        $activities = ActivityType::all();
+        $activity = $activities[array_rand($activities)];
+        return [
+            'type' => $activity,
+            'detail' => 'Activity detail for ' . $activity,
+            'user_id' => User::factory(),
+            'ip' => $this->faker->ipv4(),
+            'loggable_id' => null,
+            'loggable_type' => null,
+        ];
+    }
+}
diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php
index efbd183b3..2b7fb9ac9 100644
--- a/database/factories/Activity/Models/CommentFactory.php
+++ b/database/factories/Activity/Models/CommentFactory.php
@@ -2,6 +2,7 @@
 
 namespace Database\Factories\Activity\Models;
 
+use BookStack\Users\Models\User;
 use Illuminate\Database\Eloquent\Factories\Factory;
 
 class CommentFactory extends Factory
@@ -13,6 +14,11 @@ class CommentFactory extends Factory
      */
     protected $model = \BookStack\Activity\Models\Comment::class;
 
+    /**
+     * A static counter to provide a unique local_id for each comment.
+     */
+    protected static int $nextLocalId = 1000;
+
     /**
      * Define the model's default state.
      *
@@ -22,11 +28,18 @@ class CommentFactory extends Factory
     {
         $text = $this->faker->paragraph(1);
         $html = '<p>' . $text . '</p>';
+        $nextLocalId = static::$nextLocalId++;
+
+        $user = User::query()->first();
 
         return [
             'html'      => $html,
             'parent_id' => null,
-            'local_id'  => 1,
+            'local_id'  => $nextLocalId,
+            'content_ref' => '',
+            'archived' => false,
+            'created_by' => $user ?? User::factory(),
+            'updated_by' => $user ?? User::factory(),
         ];
     }
 }
diff --git a/database/factories/Activity/Models/FavouriteFactory.php b/database/factories/Activity/Models/FavouriteFactory.php
new file mode 100644
index 000000000..75fba86b3
--- /dev/null
+++ b/database/factories/Activity/Models/FavouriteFactory.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Database\Factories\Activity\Models;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Activity\Models\Favourite>
+ */
+class FavouriteFactory extends Factory
+{
+    protected $model = \BookStack\Activity\Models\Favourite::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        $book = Book::query()->first();
+
+        return [
+            'user_id' => User::factory(),
+            'favouritable_id' => $book->id,
+            'favouritable_type' => 'book',
+        ];
+    }
+}
diff --git a/database/factories/Activity/Models/WatchFactory.php b/database/factories/Activity/Models/WatchFactory.php
new file mode 100644
index 000000000..0f8b9e6f7
--- /dev/null
+++ b/database/factories/Activity/Models/WatchFactory.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Database\Factories\Activity\Models;
+
+use BookStack\Activity\WatchLevels;
+use BookStack\Entities\Models\Book;
+use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Activity\Models\Watch>
+ */
+class WatchFactory extends Factory
+{
+    protected $model = \BookStack\Activity\Models\Watch::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        $book = Book::factory()->create();
+
+        return [
+            'user_id' => User::factory(),
+            'watchable_id' => $book->id,
+            'watchable_type' => 'book',
+            'level' => WatchLevels::NEW,
+        ];
+    }
+}
diff --git a/database/factories/Entities/Models/ChapterFactory.php b/database/factories/Entities/Models/ChapterFactory.php
index 1fc49933e..abf554ac8 100644
--- a/database/factories/Entities/Models/ChapterFactory.php
+++ b/database/factories/Entities/Models/ChapterFactory.php
@@ -26,7 +26,8 @@ class ChapterFactory extends Factory
             'name'        => $this->faker->sentence(),
             'slug'        => Str::random(10),
             'description' => $description,
-            'description_html' => '<p>' . e($description) . '</p>'
+            'description_html' => '<p>' . e($description) . '</p>',
+            'priority' => 5,
         ];
     }
 }
diff --git a/database/factories/Entities/Models/DeletionFactory.php b/database/factories/Entities/Models/DeletionFactory.php
new file mode 100644
index 000000000..2368447cd
--- /dev/null
+++ b/database/factories/Entities/Models/DeletionFactory.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Database\Factories\Entities\Models;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Entities\Models\Deletion>
+ */
+class DeletionFactory extends Factory
+{
+    protected $model = \BookStack\Entities\Models\Deletion::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'deleted_by' => User::factory(),
+            'deletable_id' => Page::factory(),
+            'deletable_type' => 'page',
+        ];
+    }
+}
diff --git a/database/factories/Entities/Models/PageFactory.php b/database/factories/Entities/Models/PageFactory.php
index 319d97880..538ee5f3e 100644
--- a/database/factories/Entities/Models/PageFactory.php
+++ b/database/factories/Entities/Models/PageFactory.php
@@ -2,6 +2,7 @@
 
 namespace Database\Factories\Entities\Models;
 
+use BookStack\Entities\Tools\PageEditorType;
 use Illuminate\Database\Eloquent\Factories\Factory;
 use Illuminate\Support\Str;
 
@@ -16,10 +17,8 @@ class PageFactory extends Factory
 
     /**
      * Define the model's default state.
-     *
-     * @return array
      */
-    public function definition()
+    public function definition(): array
     {
         $html = '<p>' . implode('</p>', $this->faker->paragraphs(5)) . '</p>';
 
@@ -29,6 +28,8 @@ class PageFactory extends Factory
             'html'           => $html,
             'text'           => strip_tags($html),
             'revision_count' => 1,
+            'editor'         => 'wysiwyg',
+            'priority'       => 1,
         ];
     }
 }
diff --git a/database/factories/Entities/Models/PageRevisionFactory.php b/database/factories/Entities/Models/PageRevisionFactory.php
new file mode 100644
index 000000000..333916931
--- /dev/null
+++ b/database/factories/Entities/Models/PageRevisionFactory.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Database\Factories\Entities\Models;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class PageRevisionFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Entities\Models\PageRevision::class;
+
+    /**
+     * Define the model's default state.
+     */
+    public function definition(): array
+    {
+        $html = '<p>' . implode('</p>', $this->faker->paragraphs(5)) . '</p>';
+        $page = Page::query()->first();
+
+        return [
+            'page_id'        => $page->id,
+            'name'           => $this->faker->sentence(),
+            'html'           => $html,
+            'text'           => strip_tags($html),
+            'created_by'     => User::factory(),
+            'slug'           => $page->slug,
+            'book_slug'      => $page->book->slug,
+            'type'           => 'version',
+            'markdown'       => strip_tags($html),
+            'summary'        => $this->faker->sentence(),
+            'revision_number' => rand(1, 4000),
+        ];
+    }
+}
diff --git a/database/factories/Entities/Models/SlugHistoryFactory.php b/database/factories/Entities/Models/SlugHistoryFactory.php
new file mode 100644
index 000000000..c8c57e09c
--- /dev/null
+++ b/database/factories/Entities/Models/SlugHistoryFactory.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Database\Factories\Entities\Models;
+
+use BookStack\Entities\Models\Book;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Entities\Models\SlugHistory>
+ */
+class SlugHistoryFactory extends Factory
+{
+    protected $model = \BookStack\Entities\Models\SlugHistory::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'sluggable_id' => Book::factory(),
+            'sluggable_type' => 'book',
+            'slug' => $this->faker->slug(),
+            'parent_slug' => null,
+        ];
+    }
+}
diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php
index 5d0b4f892..cdb019dd3 100644
--- a/database/factories/Exports/ImportFactory.php
+++ b/database/factories/Exports/ImportFactory.php
@@ -24,6 +24,7 @@ class ImportFactory extends Factory
             'path' => 'uploads/files/imports/' . Str::random(10) . '.zip',
             'name' => $this->faker->words(3, true),
             'type' => 'book',
+            'size' => rand(1, 1001),
             'metadata' => '{"name": "My book"}',
             'created_at' => User::factory(),
         ];
diff --git a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php
index 0e25c1d60..a8f1843ed 100644
--- a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php
+++ b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php
@@ -25,9 +25,6 @@ return new class extends Migration
                 $table->unsignedInteger('owner_id')->nullable()->index();
             });
         }
-
-        // Rebuild permissions
-        app(JointPermissionBuilder::class)->rebuildForAll();
     }
 
     /**
diff --git a/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php b/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php
new file mode 100644
index 000000000..794201dec
--- /dev/null
+++ b/database/migrations/2025_04_18_215145_add_content_refs_and_archived_to_comments.php
@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            $table->string('content_ref');
+            $table->boolean('archived')->index();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            $table->dropColumn('content_ref');
+            $table->dropColumn('archived');
+        });
+    }
+};
diff --git a/database/migrations/2025_09_02_111542_remove_unused_columns.php b/database/migrations/2025_09_02_111542_remove_unused_columns.php
new file mode 100644
index 000000000..3a36e5ad6
--- /dev/null
+++ b/database/migrations/2025_09_02_111542_remove_unused_columns.php
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            $table->dropColumn('text');
+        });
+
+        Schema::table('role_permissions', function (Blueprint $table) {
+            $table->dropColumn('display_name');
+            $table->dropColumn('description');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            $table->longText('text')->nullable();
+        });
+
+        Schema::table('role_permissions', function (Blueprint $table) {
+            $table->string('display_name')->nullable();
+            $table->string('description')->nullable();
+        });
+    }
+};
diff --git a/database/migrations/2025_09_15_132850_create_entities_table.php b/database/migrations/2025_09_15_132850_create_entities_table.php
new file mode 100644
index 000000000..6c890d719
--- /dev/null
+++ b/database/migrations/2025_09_15_132850_create_entities_table.php
@@ -0,0 +1,71 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('entities', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->string('type', 10)->index();
+            $table->string('name');
+            $table->string('slug')->index();
+
+            $table->unsignedBigInteger('book_id')->nullable()->index();
+            $table->unsignedBigInteger('chapter_id')->nullable()->index();
+            $table->unsignedInteger('priority')->nullable();
+
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable()->index();
+            $table->timestamp('deleted_at')->nullable()->index();
+
+            $table->unsignedInteger('created_by')->nullable();
+            $table->unsignedInteger('updated_by')->nullable();
+            $table->unsignedInteger('owned_by')->nullable()->index();
+
+            $table->primary(['id', 'type'], 'entities_pk');
+        });
+
+        Schema::create('entity_container_data', function (Blueprint $table) {
+            $table->unsignedBigInteger('entity_id');
+            $table->string('entity_type', 10);
+            $table->text('description');
+            $table->text('description_html');
+
+            $table->unsignedBigInteger('default_template_id')->nullable();
+            $table->unsignedInteger('image_id')->nullable();
+            $table->unsignedInteger('sort_rule_id')->nullable();
+
+            $table->primary(['entity_id', 'entity_type'], 'entity_container_data_pk');
+        });
+
+        Schema::create('entity_page_data', function (Blueprint $table) {
+            $table->unsignedBigInteger('page_id')->primary();
+
+            $table->boolean('draft')->index();
+            $table->boolean('template')->index();
+            $table->unsignedInteger('revision_count');
+            $table->string('editor', 50);
+
+            $table->longText('html');
+            $table->longText('text');
+            $table->longText('markdown');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('entities');
+        Schema::dropIfExists('entity_container_data');
+        Schema::dropIfExists('entity_page_data');
+    }
+};
diff --git a/database/migrations/2025_09_15_134701_migrate_entity_data.php b/database/migrations/2025_09_15_134701_migrate_entity_data.php
new file mode 100644
index 000000000..7b4beef06
--- /dev/null
+++ b/database/migrations/2025_09_15_134701_migrate_entity_data.php
@@ -0,0 +1,90 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        // Start a transaction to avoid leaving a message DB on error
+        DB::beginTransaction();
+
+        // Migrate book/shelf data to entities
+        foreach (['books' => 'book', 'bookshelves' => 'bookshelf'] as $table => $type) {
+            DB::table('entities')->insertUsing([
+                'id', 'type', 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+            ], DB::table($table)->select([
+                'id', DB::raw("'{$type}'"), 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+            ]));
+        }
+
+        // Migrate chapter data to entities
+        DB::table('entities')->insertUsing([
+            'id', 'type', 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+        ], DB::table('chapters')->select([
+            'id', DB::raw("'chapter'"), 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+        ]));
+
+        DB::table('entities')->insertUsing([
+            'id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+        ], DB::table('pages')->select([
+            'id', DB::raw("'page'"), 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by',
+        ]));
+
+        // Migrate shelf data to entity_container_data
+        DB::table('entity_container_data')->insertUsing([
+            'entity_id', 'entity_type', 'description', 'description_html', 'image_id',
+        ], DB::table('bookshelves')->select([
+            'id', DB::raw("'bookshelf'"), 'description', 'description_html', 'image_id',
+        ]));
+
+        // Migrate book data to entity_container_data
+        DB::table('entity_container_data')->insertUsing([
+            'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id'
+        ], DB::table('books')->select([
+            'id', DB::raw("'book'"), 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id'
+        ]));
+
+        // Migrate chapter data to entity_container_data
+        DB::table('entity_container_data')->insertUsing([
+            'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id',
+        ], DB::table('chapters')->select([
+            'id', DB::raw("'chapter'"), 'description', 'description_html', 'default_template_id',
+        ]));
+
+        // Migrate page data to entity_page_data
+        DB::table('entity_page_data')->insertUsing([
+            'page_id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown',
+        ], DB::table('pages')->select([
+            'id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown',
+        ]));
+
+        // Fix up data - Convert 0 id references to null
+        DB::table('entities')->where('created_by', '=', 0)->update(['created_by' => null]);
+        DB::table('entities')->where('updated_by', '=', 0)->update(['updated_by' => null]);
+        DB::table('entities')->where('owned_by', '=', 0)->update(['owned_by' => null]);
+        DB::table('entities')->where('chapter_id', '=', 0)->update(['chapter_id' => null]);
+
+        // Fix up data - Convert any missing id-based references to null
+        $userIdQuery = DB::table('users')->select('id');
+        DB::table('entities')->whereNotIn('created_by', $userIdQuery)->update(['created_by' => null]);
+        DB::table('entities')->whereNotIn('updated_by', $userIdQuery)->update(['updated_by' => null]);
+        DB::table('entities')->whereNotIn('owned_by', $userIdQuery)->update(['owned_by' => null]);
+        DB::table('entities')->whereNotIn('chapter_id', DB::table('chapters')->select('id'))->update(['chapter_id' => null]);
+
+        // Commit our changes within our transaction
+        DB::commit();
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        // No action here since the actual data remains in the database for the old tables,
+        // so data reversion actions are done in a later migration when the old tables are dropped.
+    }
+};
diff --git a/database/migrations/2025_09_15_134751_update_entity_relation_columns.php b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php
new file mode 100644
index 000000000..f8622d7c6
--- /dev/null
+++ b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php
@@ -0,0 +1,125 @@
+<?php
+
+use BookStack\Permissions\JointPermissionBuilder;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Query\Builder;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * @var array<string, string|array<string>> $columnByTable
+     */
+    protected static array $columnByTable = [
+        'activities' => 'loggable_id',
+        'attachments' => 'uploaded_to',
+        'bookshelves_books' => ['bookshelf_id', 'book_id'],
+        'comments' => 'entity_id',
+        'deletions' => 'deletable_id',
+        'entity_permissions' => 'entity_id',
+        'favourites' => 'favouritable_id',
+        'images' => 'uploaded_to',
+        'joint_permissions' => 'entity_id',
+        'page_revisions' => 'page_id',
+        'references' => ['from_id', 'to_id'],
+        'search_terms' => 'entity_id',
+        'tags' => 'entity_id',
+        'views' => 'viewable_id',
+        'watches' => 'watchable_id',
+    ];
+
+    protected static array $nullable = [
+        'activities.loggable_id',
+        'images.uploaded_to',
+    ];
+
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        // Drop foreign key constraints
+        Schema::table('bookshelves_books', function (Blueprint $table) {
+            $table->dropForeign(['book_id']);
+            $table->dropForeign(['bookshelf_id']);
+        });
+
+        // Update column types to unsigned big integers
+        foreach (static::$columnByTable as $table => $column) {
+            $tableName = $table;
+            Schema::table($table, function (Blueprint $table) use ($tableName, $column) {
+                if (is_string($column)) {
+                    $column = [$column];
+                }
+
+                foreach ($column as $col) {
+                    if (in_array($tableName . '.' . $col, static::$nullable)) {
+                        $table->unsignedBigInteger($col)->nullable()->change();
+                    } else {
+                        $table->unsignedBigInteger($col)->change();
+                    }
+                }
+            });
+        }
+
+        // Convert image and activity zero values to null
+        DB::table('images')->where('uploaded_to', '=', 0)->update(['uploaded_to' => null]);
+        DB::table('activities')->where('loggable_id', '=', 0)->update(['loggable_id' => null]);
+
+        // Clean up any orphaned gallery/drawio images to nullify their page relation
+        DB::table('images')
+            ->whereIn('type', ['gallery', 'drawio'])
+            ->whereNotIn('uploaded_to', function (Builder $query) {
+                $query->select('id')
+                    ->from('entities')
+                    ->where('type', '=', 'page');
+            })->update(['uploaded_to' => null]);
+
+        // Rebuild joint permissions if needed
+        // This was moved here from 2023_01_24_104625_refactor_joint_permissions_storage since the changes
+        // made for this release would mean our current logic would not be compatible with
+        // the database changes being made. This is based on a count since any joint permissions
+        // would have been truncated in the previous migration.
+        if (\Illuminate\Support\Facades\DB::table('joint_permissions')->count() === 0) {
+            app(JointPermissionBuilder::class)->rebuildForAll();
+        }
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        // Convert image null values back to zeros
+        DB::table('images')->whereNull('uploaded_to')->update(['uploaded_to' => '0']);
+
+        // Revert columns to standard integers
+        foreach (static::$columnByTable as $table => $column) {
+            $tableName = $table;
+            Schema::table($table, function (Blueprint $table) use ($tableName, $column) {
+                if (is_string($column)) {
+                    $column = [$column];
+                }
+
+                foreach ($column as $col) {
+                    if ($tableName . '.' . $col === 'activities.loggable_id') {
+                        $table->unsignedInteger($col)->nullable()->change();
+                    } else if ($tableName . '.' . $col === 'images.uploaded_to') {
+                        $table->unsignedInteger($col)->default(0)->change();
+                    } else {
+                        $table->unsignedInteger($col)->change();
+                    }
+                }
+            });
+        }
+
+        // Re-add foreign key constraints
+        Schema::table('bookshelves_books', function (Blueprint $table) {
+            $table->foreign('bookshelf_id')->references('id')->on('bookshelves')
+                ->onUpdate('cascade')->onDelete('cascade');
+            $table->foreign('book_id')->references('id')->on('books')
+                ->onUpdate('cascade')->onDelete('cascade');
+        });
+    }
+};
diff --git a/database/migrations/2025_09_15_134813_drop_old_entity_tables.php b/database/migrations/2025_09_15_134813_drop_old_entity_tables.php
new file mode 100644
index 000000000..d6360f74d
--- /dev/null
+++ b/database/migrations/2025_09_15_134813_drop_old_entity_tables.php
@@ -0,0 +1,162 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Query\JoinClause;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::dropIfExists('pages');
+        Schema::dropIfExists('chapters');
+        Schema::dropIfExists('books');
+        Schema::dropIfExists('bookshelves');
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::create('pages', function (Blueprint $table) {
+            $table->unsignedInteger('id', true)->primary();
+            $table->integer('book_id')->index();
+            $table->integer('chapter_id')->index();
+            $table->string('name');
+            $table->string('slug')->index();
+            $table->longText('html');
+            $table->longText('text');
+            $table->integer('priority')->index();
+
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable()->index();
+            $table->integer('created_by')->index();
+            $table->integer('updated_by')->index();
+
+            $table->boolean('draft')->default(0)->index();
+            $table->longText('markdown');
+            $table->integer('revision_count');
+            $table->boolean('template')->default(0)->index();
+            $table->timestamp('deleted_at')->nullable();
+
+            $table->unsignedInteger('owned_by')->index();
+            $table->string('editor', 50)->default('');
+        });
+
+        Schema::create('chapters', function (Blueprint $table) {
+            $table->unsignedInteger('id', true)->primary();
+            $table->integer('book_id')->index();
+            $table->string('slug')->index();
+            $table->text('name');
+            $table->text('description');
+            $table->integer('priority')->index();
+
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable();
+            $table->integer('created_by')->index();
+            $table->integer('updated_by')->index();
+
+            $table->timestamp('deleted_at')->nullable();
+            $table->unsignedInteger('owned_by')->index();
+            $table->text('description_html');
+            $table->integer('default_template_id')->nullable();
+        });
+
+        Schema::create('books', function (Blueprint $table) {
+            $table->unsignedInteger('id', true)->primary();
+            $table->string('name');
+            $table->string('slug')->index();
+            $table->text('description');
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable();
+
+            $table->integer('created_by')->index();
+            $table->integer('updated_by')->index();
+
+            $table->integer('image_id')->nullable();
+            $table->timestamp('deleted_at')->nullable();
+            $table->unsignedInteger('owned_by')->index();
+
+            $table->integer('default_template_id')->nullable();
+            $table->text('description_html');
+            $table->unsignedInteger('sort_rule_id')->nullable();
+        });
+
+        Schema::create('bookshelves', function (Blueprint $table) {
+            $table->unsignedInteger('id', true)->primary();
+            $table->string('name', 180);
+            $table->string('slug', 180)->index();
+            $table->text('description');
+
+            $table->integer('created_by')->index();
+            $table->integer('updated_by')->index();
+            $table->integer('image_id')->nullable();
+
+            $table->timestamp('created_at')->nullable();
+            $table->timestamp('updated_at')->nullable();
+            $table->timestamp('deleted_at')->nullable();
+
+            $table->unsignedInteger('owned_by')->index();
+            $table->text('description_html');
+        });
+
+        DB::beginTransaction();
+
+        // Revert nulls back to zeros
+        DB::table('entities')->whereNull('created_by')->update(['created_by' => 0]);
+        DB::table('entities')->whereNull('updated_by')->update(['updated_by' => 0]);
+        DB::table('entities')->whereNull('owned_by')->update(['owned_by' => 0]);
+        DB::table('entities')->whereNull('chapter_id')->update(['chapter_id' => 0]);
+
+        // Restore data back into pages table
+        $pageFields = [
+            'id', 'book_id', 'chapter_id', 'name', 'slug', 'html', 'text', 'priority', 'created_at', 'updated_at',
+            'created_by', 'updated_by', 'draft', 'markdown', 'revision_count', 'template', 'deleted_at', 'owned_by', 'editor'
+        ];
+        $pageQuery = DB::table('entities')->select($pageFields)
+            ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id')
+            ->where('type', '=', 'page');
+        DB::table('pages')->insertUsing($pageFields, $pageQuery);
+
+        // Restore data back into chapters table
+        $containerJoinClause = function (JoinClause $join) {
+            return $join->on('entities.id', '=', 'entity_container_data.entity_id')
+                ->on('entities.type', '=', 'entity_container_data.entity_type');
+        };
+        $chapterFields = [
+            'id', 'book_id', 'slug', 'name', 'description', 'priority', 'created_at', 'updated_at', 'created_by', 'updated_by',
+            'deleted_at', 'owned_by', 'description_html', 'default_template_id'
+        ];
+        $chapterQuery = DB::table('entities')->select($chapterFields)
+            ->leftJoin('entity_container_data', $containerJoinClause)
+            ->where('type', '=', 'chapter');
+        DB::table('chapters')->insertUsing($chapterFields, $chapterQuery);
+
+        // Restore data back into books table
+        $bookFields = [
+            'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id',
+            'deleted_at', 'owned_by', 'default_template_id', 'description_html', 'sort_rule_id'
+        ];
+        $bookQuery = DB::table('entities')->select($bookFields)
+            ->leftJoin('entity_container_data', $containerJoinClause)
+            ->where('type', '=', 'book');
+        DB::table('books')->insertUsing($bookFields, $bookQuery);
+
+        // Restore data back into bookshelves table
+        $shelfFields = [
+            'id', 'name', 'slug', 'description',  'created_by', 'updated_by', 'image_id', 'created_at', 'updated_at',
+            'deleted_at', 'owned_by', 'description_html',
+        ];
+        $shelfQuery = DB::table('entities')->select($shelfFields)
+            ->leftJoin('entity_container_data', $containerJoinClause)
+            ->where('type', '=', 'bookshelf');
+        DB::table('bookshelves')->insertUsing($shelfFields, $shelfQuery);
+
+        DB::commit();
+    }
+};
diff --git a/database/migrations/2025_10_18_163331_clean_user_id_references.php b/database/migrations/2025_10_18_163331_clean_user_id_references.php
new file mode 100644
index 000000000..42e670139
--- /dev/null
+++ b/database/migrations/2025_10_18_163331_clean_user_id_references.php
@@ -0,0 +1,76 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    protected static array $toNullify = [
+        'attachments' => ['created_by', 'updated_by'],
+        'comments' => ['created_by', 'updated_by'],
+        'deletions' => ['deleted_by'],
+        'entities' => ['created_by', 'updated_by', 'owned_by'],
+        'images' => ['created_by', 'updated_by'],
+        'imports' => ['created_by'],
+        'joint_permissions' => ['owner_id'],
+        'page_revisions' => ['created_by'],
+    ];
+
+    protected static array $toClean = [
+        'api_tokens' => ['user_id'],
+        'email_confirmations' => ['user_id'],
+        'favourites' => ['user_id'],
+        'mfa_values' => ['user_id'],
+        'role_user' => ['user_id'],
+        'sessions' => ['user_id'],
+        'social_accounts' => ['user_id'],
+        'user_invites' => ['user_id'],
+        'views' => ['user_id'],
+        'watches' => ['user_id'],
+    ];
+
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        $idSelectQuery = DB::table('users')->select('id');
+
+        foreach (self::$toNullify as $tableName => $columns) {
+            Schema::table($tableName, function (Blueprint $table) use ($columns) {
+                foreach ($columns as $columnName) {
+                    $table->unsignedInteger($columnName)->nullable()->change();
+                }
+            });
+
+            foreach ($columns as $columnName) {
+                DB::table($tableName)->where($columnName, '=', 0)->update([$columnName => null]);
+                DB::table($tableName)->whereNotIn($columnName, $idSelectQuery)->update([$columnName => null]);
+            }
+        }
+
+        foreach (self::$toClean as $tableName => $columns) {
+            foreach ($columns as $columnName) {
+                DB::table($tableName)->whereNotIn($columnName, $idSelectQuery)->delete();
+            }
+        }
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        foreach (self::$toNullify as $tableName => $columns) {
+            foreach ($columns as $columnName) {
+                DB::table($tableName)->whereNull($columnName)->update([$columnName => 0]);
+            }
+
+            Schema::table($tableName, function (Blueprint $table) use ($columns) {
+                foreach ($columns as $columnName) {
+                    $table->unsignedInteger($columnName)->nullable(false)->change();
+                }
+            });
+        }
+    }
+};
diff --git a/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php b/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php
new file mode 100644
index 000000000..de13453de
--- /dev/null
+++ b/database/migrations/2025_10_22_134507_update_comments_relation_field_names.php
@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            $table->renameColumn('entity_id', 'commentable_id');
+            $table->renameColumn('entity_type', 'commentable_type');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            $table->renameColumn('commentable_id', 'entity_id');
+            $table->renameColumn('commentable_type', 'entity_type');
+        });
+    }
+};
diff --git a/database/migrations/2025_11_23_161812_create_slug_history_table.php b/database/migrations/2025_11_23_161812_create_slug_history_table.php
new file mode 100644
index 000000000..df30bf030
--- /dev/null
+++ b/database/migrations/2025_11_23_161812_create_slug_history_table.php
@@ -0,0 +1,51 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        // Create the table for storing slug history
+        Schema::create('slug_history', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('sluggable_type', 10)->index();
+            $table->unsignedBigInteger('sluggable_id')->index();
+            $table->string('slug')->index();
+            $table->string('parent_slug')->nullable()->index();
+            $table->timestamps();
+        });
+
+        // Migrate in slugs from page revisions
+        $revisionSlugQuery = DB::table('page_revisions')
+            ->select([
+                DB::raw('\'page\' as sluggable_type'),
+                'page_id as sluggable_id',
+                'slug',
+                'book_slug as parent_slug',
+                DB::raw('min(created_at) as created_at'),
+                DB::raw('min(updated_at) as updated_at'),
+            ])
+            ->where('type', '=', 'version')
+            ->groupBy(['sluggable_id', 'slug', 'parent_slug']);
+
+        DB::table('slug_history')->insertUsing(
+            ['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
+            $revisionSlugQuery,
+        );
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('slug_history');
+    }
+};
diff --git a/database/seeders/DummyContentSeeder.php b/database/seeders/DummyContentSeeder.php
index a4383be50..845bea8e4 100644
--- a/database/seeders/DummyContentSeeder.php
+++ b/database/seeders/DummyContentSeeder.php
@@ -12,7 +12,9 @@ use BookStack\Permissions\Models\RolePermission;
 use BookStack\Search\SearchIndex;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Seeder;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
 
@@ -20,10 +22,8 @@ class DummyContentSeeder extends Seeder
 {
     /**
      * Run the database seeds.
-     *
-     * @return void
      */
-    public function run()
+    public function run(): void
     {
         // Create an editor user
         $editorUser = User::factory()->create();
@@ -39,40 +39,58 @@ class DummyContentSeeder extends Seeder
 
         $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id];
 
-        Book::factory()->count(5)->create($byData)
+        Book::factory()->count(5)->make($byData)
             ->each(function ($book) use ($byData) {
+                $book->save();
                 $chapters = Chapter::factory()->count(3)->create($byData)
                     ->each(function ($chapter) use ($book, $byData) {
                         $pages = Page::factory()->count(3)->make(array_merge($byData, ['book_id' => $book->id]));
-                        $chapter->pages()->saveMany($pages);
+                        $this->saveManyOnRelation($pages, $chapter->pages());
                     });
                 $pages = Page::factory()->count(3)->make($byData);
-                $book->chapters()->saveMany($chapters);
-                $book->pages()->saveMany($pages);
+                $this->saveManyOnRelation($chapters, $book->chapters());
+                $this->saveManyOnRelation($pages, $book->pages());
             });
 
-        $largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
+        $largeBook = Book::factory()->make(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
+        $largeBook->save();
+
         $pages = Page::factory()->count(200)->make($byData);
         $chapters = Chapter::factory()->count(50)->make($byData);
-        $largeBook->pages()->saveMany($pages);
-        $largeBook->chapters()->saveMany($chapters);
+        $this->saveManyOnRelation($pages, $largeBook->pages());
+        $this->saveManyOnRelation($chapters, $largeBook->chapters());
+
+        $shelves = Bookshelf::factory()->count(10)->make($byData);
+        foreach ($shelves as $shelf) {
+            $shelf->save();
+        }
 
-        $shelves = Bookshelf::factory()->count(10)->create($byData);
         $largeBook->shelves()->attach($shelves->pluck('id'));
 
         // Assign API permission to editor role and create an API key
         $apiPermission = RolePermission::getByName('access-api');
         $editorRole->attachPermission($apiPermission);
         $token = (new ApiToken())->forceFill([
-            'user_id'    => $editorUser->id,
-            'name'       => 'Testing API key',
+            'user_id' => $editorUser->id,
+            'name' => 'Testing API key',
             'expires_at' => ApiToken::defaultExpiry(),
-            'secret'     => Hash::make('password'),
-            'token_id'   => 'apitoken',
+            'secret' => Hash::make('password'),
+            'token_id' => 'apitoken',
         ]);
         $token->save();
 
         app(JointPermissionBuilder::class)->rebuildForAll();
         app(SearchIndex::class)->indexAllEntities();
     }
+
+    /**
+     * Inefficient workaround for saving many on a relation since we can't directly insert
+     * entities since we split them across tables.
+     */
+    protected function saveManyOnRelation(Collection $entities, HasMany $relation): void
+    {
+        foreach ($entities as $entity) {
+            $relation->save($entity);
+        }
+    }
 }
diff --git a/dev/api/requests/comments-create.json b/dev/api/requests/comments-create.json
new file mode 100644
index 000000000..abc6832d8
--- /dev/null
+++ b/dev/api/requests/comments-create.json
@@ -0,0 +1,5 @@
+{
+	"page_id": 2646,
+	"html": "<p>Can the title be updated?</p>",
+	"content_ref": "bkmrk-page-title:7341676876991010:3-14"
+}
\ No newline at end of file
diff --git a/dev/api/requests/comments-update.json b/dev/api/requests/comments-update.json
new file mode 100644
index 000000000..bbcf114f3
--- /dev/null
+++ b/dev/api/requests/comments-update.json
@@ -0,0 +1,4 @@
+{
+	"html": "<p>Can this comment be updated??????</p>",
+	"archived": true
+}
\ No newline at end of file
diff --git a/dev/api/requests/image-gallery-readDataForUrl.http b/dev/api/requests/image-gallery-readDataForUrl.http
new file mode 100644
index 000000000..1892600f4
--- /dev/null
+++ b/dev/api/requests/image-gallery-readDataForUrl.http
@@ -0,0 +1 @@
+GET /api/image-gallery/url/data?url=https%3A%2F%2Fbookstack.example.com%2Fuploads%2Fimages%2Fgallery%2F2025-10%2Fmy-image.png
diff --git a/dev/api/requests/imports-run.json b/dev/api/requests/imports-run.json
new file mode 100644
index 000000000..836a66f3a
--- /dev/null
+++ b/dev/api/requests/imports-run.json
@@ -0,0 +1,4 @@
+{
+  "parent_type": "book",
+  "parent_id": 28
+}
\ No newline at end of file
diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json
index afeebade6..582744f99 100644
--- a/dev/api/responses/books-read.json
+++ b/dev/api/responses/books-read.json
@@ -52,7 +52,7 @@
       "name": "Cool Animals",
       "slug": "cool-animals",
       "book_id": 16,
-      "chapter_id": 0,
+      "chapter_id": null,
       "draft": false,
       "template": false,
       "created_at": "2021-12-19T18:22:11.000000Z",
diff --git a/dev/api/responses/comments-create.json b/dev/api/responses/comments-create.json
new file mode 100644
index 000000000..6fec5c101
--- /dev/null
+++ b/dev/api/responses/comments-create.json
@@ -0,0 +1,13 @@
+{
+	"id": 167,
+	"commentable_id": 2646,
+	"commentable_type": "page",
+	"parent_id": null,
+	"local_id": 29,
+	"created_by": 1,
+	"updated_by": 1,
+	"created_at": "2025-10-24T14:05:41.000000Z",
+	"updated_at": "2025-10-24T14:05:41.000000Z",
+	"content_ref": "bkmrk-page-title:7341676876991010:3-14",
+	"archived": false
+}
\ No newline at end of file
diff --git a/dev/api/responses/comments-list.json b/dev/api/responses/comments-list.json
new file mode 100644
index 000000000..a0f638120
--- /dev/null
+++ b/dev/api/responses/comments-list.json
@@ -0,0 +1,29 @@
+{
+	"data": [
+		{
+			"id": 1,
+			"commentable_id": 2607,
+			"commentable_type": "page",
+			"parent_id": null,
+			"local_id": 1,
+			"content_ref": "",
+			"created_by": 1,
+			"updated_by": 1,
+			"created_at": "2022-04-20T08:43:27.000000Z",
+			"updated_at": "2022-04-20T08:43:27.000000Z"
+		},
+		{
+			"id": 18,
+			"commentable_id": 2607,
+			"commentable_type": "page",
+			"parent_id": 1,
+			"local_id": 2,
+			"content_ref": "",
+			"created_by": 3,
+			"updated_by": 3,
+			"created_at": "2022-11-15T08:12:35.000000Z",
+			"updated_at": "2022-11-15T08:12:35.000000Z"
+		}
+	],
+	"total": 88
+}
\ No newline at end of file
diff --git a/dev/api/responses/comments-read.json b/dev/api/responses/comments-read.json
new file mode 100644
index 000000000..054b8ad19
--- /dev/null
+++ b/dev/api/responses/comments-read.json
@@ -0,0 +1,38 @@
+{
+	"id": 22,
+	"commentable_id": 2646,
+	"commentable_type": "page",
+	"html": "<p>This page looks great!<\/p>\n",
+	"parent_id": null,
+	"local_id": 2,
+	"created_by": {
+		"id": 1,
+		"name": "Admin",
+		"slug": "admin"
+	},
+	"updated_by": {
+		"id": 1,
+		"name": "Admin",
+		"slug": "admin"
+	},
+	"created_at": "2023-06-07T07:50:56.000000Z",
+	"updated_at": "2023-06-07T07:50:56.000000Z",
+	"content_ref": "",
+	"archived": false,
+	"replies": [
+		{
+			"id": 34,
+			"commentable_id": 2646,
+			"commentable_type": "page",
+			"html": "<p>Thanks for the comment!<\/p>\n",
+			"parent_id": 2,
+			"local_id": 10,
+			"created_by": 2,
+			"updated_by": 2,
+			"created_at": "2023-06-07T13:46:25.000000Z",
+			"updated_at": "2023-06-07T13:46:25.000000Z",
+			"content_ref": "",
+			"archived": false
+		}
+	]
+}
\ No newline at end of file
diff --git a/dev/api/responses/comments-update.json b/dev/api/responses/comments-update.json
new file mode 100644
index 000000000..ce5ed2644
--- /dev/null
+++ b/dev/api/responses/comments-update.json
@@ -0,0 +1,13 @@
+{
+	"id": 167,
+	"commentable_id": 2646,
+	"commentable_type": "page",
+	"parent_id": null,
+	"local_id": 29,
+	"created_by": 1,
+	"updated_by": 1,
+	"created_at": "2025-10-24T14:05:41.000000Z",
+	"updated_at": "2025-10-24T14:09:56.000000Z",
+	"content_ref": "bkmrk-page-title:7341676876991010:3-14",
+	"archived": true
+}
\ No newline at end of file
diff --git a/dev/api/responses/imports-create.json b/dev/api/responses/imports-create.json
new file mode 100644
index 000000000..997758799
--- /dev/null
+++ b/dev/api/responses/imports-create.json
@@ -0,0 +1,10 @@
+{
+  "type": "chapter",
+  "name": "Pension Providers",
+  "created_by": 1,
+  "size": 2757,
+  "path": "uploads\/files\/imports\/ghnxmS3u9QxLWu82.zip",
+  "updated_at": "2025-07-18T14:50:27.000000Z",
+  "created_at": "2025-07-18T14:50:27.000000Z",
+  "id": 31
+}
\ No newline at end of file
diff --git a/dev/api/responses/imports-list.json b/dev/api/responses/imports-list.json
new file mode 100644
index 000000000..7451e4437
--- /dev/null
+++ b/dev/api/responses/imports-list.json
@@ -0,0 +1,23 @@
+{
+  "data": [
+    {
+      "id": 25,
+      "name": "IT Department",
+      "size": 618462,
+      "type": "book",
+      "created_by": 1,
+      "created_at": "2024-12-20T18:40:38.000000Z",
+      "updated_at": "2024-12-20T18:40:38.000000Z"
+    },
+    {
+      "id": 27,
+      "name": "Clients",
+      "size": 15364,
+      "type": "chapter",
+      "created_by": 1,
+      "created_at": "2025-03-20T12:41:44.000000Z",
+      "updated_at": "2025-03-20T12:41:44.000000Z"
+    }
+  ],
+  "total": 2
+}
\ No newline at end of file
diff --git a/dev/api/responses/imports-read.json b/dev/api/responses/imports-read.json
new file mode 100644
index 000000000..e256854d1
--- /dev/null
+++ b/dev/api/responses/imports-read.json
@@ -0,0 +1,51 @@
+{
+  "id": 25,
+  "name": "IT Department",
+  "path": "uploads\/files\/imports\/7YOpZ6sGIEbYdRFL.zip",
+  "size": 618462,
+  "type": "book",
+  "created_by": 1,
+  "created_at": "2024-12-20T18:40:38.000000Z",
+  "updated_at": "2024-12-20T18:40:38.000000Z",
+  "details": {
+    "id": 4,
+    "name": "IT Department",
+    "chapters": [
+      {
+        "id": 3,
+        "name": "Server Systems",
+        "priority": 1,
+        "pages": [
+          {
+            "id": 22,
+            "name": "prod-aws-stonehawk",
+            "priority": 0,
+            "attachments": [],
+            "images": [],
+            "tags": []
+          }
+        ],
+        "tags": []
+      }
+    ],
+    "pages": [
+      {
+        "id": 23,
+        "name": "Member Onboarding Guide",
+        "priority": 0,
+        "attachments": [],
+        "images": [],
+        "tags": []
+      },
+      {
+        "id": 25,
+        "name": "IT Holiday Party Event",
+        "priority": 2,
+        "attachments": [],
+        "images": [],
+        "tags": []
+      }
+    ],
+    "tags": []
+  }
+}
\ No newline at end of file
diff --git a/dev/api/responses/imports-run.json b/dev/api/responses/imports-run.json
new file mode 100644
index 000000000..90b34d6aa
--- /dev/null
+++ b/dev/api/responses/imports-run.json
@@ -0,0 +1,14 @@
+{
+  "id": 1067,
+  "book_id": 28,
+  "slug": "pension-providers",
+  "name": "Pension Providers",
+  "description": "Details on the various pension providers that are available",
+  "priority": 7,
+  "created_at": "2025-07-18T14:53:35.000000Z",
+  "updated_at": "2025-07-18T14:53:36.000000Z",
+  "created_by": 1,
+  "updated_by": 1,
+  "owned_by": 1,
+  "default_template_id": null
+}
\ No newline at end of file
diff --git a/dev/api/responses/pages-create.json b/dev/api/responses/pages-create.json
index 11f5ab8c8..705dea6f6 100644
--- a/dev/api/responses/pages-create.json
+++ b/dev/api/responses/pages-create.json
@@ -1,7 +1,7 @@
 {
 	"id": 358,
 	"book_id": 1,
-	"chapter_id": 0,
+	"chapter_id": null,
 	"name": "My API Page",
 	"slug": "my-api-page",
 	"html": "<p id=\"bkmrk-my-new-api-page\">my new API page</p>",
diff --git a/dev/api/responses/pages-read.json b/dev/api/responses/pages-read.json
index 2f3538964..e38a9cf92 100644
--- a/dev/api/responses/pages-read.json
+++ b/dev/api/responses/pages-read.json
@@ -1,7 +1,7 @@
 {
 	"id": 306,
 	"book_id": 1,
-	"chapter_id": 0,
+	"chapter_id": null,
 	"name": "A page written in markdown",
 	"slug": "a-page-written-in-markdown",
 	"html": "<h1 id=\"bkmrk-this-is-my-cool-page\">This is my cool page! With some included text</h1>",
@@ -29,6 +29,79 @@
 	"revision_count": 5,
 	"template": false,
 	"editor": "wysiwyg",
+	"comments": {
+		"active": [
+			{
+				"comment": {
+					"id": 22,
+					"commentable_id": 306,
+					"commentable_type": "page",
+					"html": "<p>Does this need revising?<\/p>\n",
+					"parent_id": null,
+					"local_id": 1,
+					"created_by": {
+						"id": 1,
+						"name": "Admin",
+						"slug": "admin"
+					},
+					"updated_by": 1,
+					"created_at": "2023-06-07T07:50:56.000000Z",
+					"updated_at": "2023-06-07T07:50:56.000000Z",
+					"content_ref": "",
+					"archived": false
+				},
+				"depth": 0,
+				"children": [
+					{
+						"comment": {
+							"id": 34,
+							"commentable_id": 2646,
+							"commentable_type": "page",
+							"html": "<p>I think it's okay!<\/p>\n",
+							"parent_id": 1,
+							"local_id": 2,
+							"created_by": {
+								"id": 2,
+								"name": "Editor",
+								"slug": "editor"
+							},
+							"updated_by": 1,
+							"created_at": "2023-06-07T13:46:25.000000Z",
+							"updated_at": "2023-06-07T13:46:25.000000Z",
+							"content_ref": "",
+							"archived": false
+						},
+						"depth": 1,
+						"children": []
+					}
+				]
+			}
+		],
+		"archived": [
+			{
+				"comment": {
+					"id": 21,
+					"commentable_id": 2646,
+					"commentable_type": "page",
+					"html": "<p>The title needs to be fixed<\/p>\n",
+					"parent_id": null,
+					"local_id": 3,
+					"created_by": {
+						"id": 2,
+						"name": "Editor",
+						"slug": "editor"
+					},
+					"updated_by": 1,
+					"created_at": "2023-06-07T07:50:49.000000Z",
+					"updated_at": "2025-10-24T08:37:22.000000Z",
+					"content_ref": "",
+					"archived": true
+				},
+				"depth": 0,
+				"children": []
+			}
+		]
+	},
 	"tags": [
 		{
 			"name": "Category",
diff --git a/dev/api/responses/recycle-bin-list.json b/dev/api/responses/recycle-bin-list.json
index 853070839..19167ec05 100644
--- a/dev/api/responses/recycle-bin-list.json
+++ b/dev/api/responses/recycle-bin-list.json
@@ -10,7 +10,7 @@
       "deletable": {
         "id": 2582,
         "book_id": 25,
-        "chapter_id": 0,
+        "chapter_id": null,
         "name": "A Wonderful Page",
         "slug": "a-wonderful-page",
         "priority": 9,
diff --git a/dev/api/responses/system-read.json b/dev/api/responses/system-read.json
new file mode 100644
index 000000000..4687d6ffc
--- /dev/null
+++ b/dev/api/responses/system-read.json
@@ -0,0 +1,7 @@
+{
+  "version": "v25.02.4",
+  "instance_id": "1234abcd-cc12-7808-af0a-264cb0cbd611",
+  "app_name": "My BookStack Instance",
+  "app_logo": "https://docs.example.com/uploads/images/system/2025-05/cat-icon.png",
+  "base_url": "https://docs.example.com"
+}
\ No newline at end of file
diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js
index cd8bf279f..63387d612 100644
--- a/dev/build/esbuild.js
+++ b/dev/build/esbuild.js
@@ -13,7 +13,7 @@ const entryPoints = {
     app: path.join(__dirname, '../../resources/js/app.ts'),
     code: path.join(__dirname, '../../resources/js/code/index.mjs'),
     'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
-    markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),
+    markdown: path.join(__dirname, '../../resources/js/markdown/index.mts'),
     wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
 };
 
diff --git a/dev/docker/db-testing/Dockerfile b/dev/docker/db-testing/Dockerfile
new file mode 100644
index 000000000..618f5bb82
--- /dev/null
+++ b/dev/docker/db-testing/Dockerfile
@@ -0,0 +1,30 @@
+FROM ubuntu:24.04
+
+# Install additional dependencies
+RUN apt-get update && \
+    apt-get install -y \
+        git \
+		wget \
+        zip \
+        unzip \
+		php \
+		php-bcmath php-curl php-mbstring php-gd php-xml php-zip php-mysql php-ldap \
+       && \
+    rm -rf /var/lib/apt/lists/*
+
+# Take branch as an argument so we can choose which BookStack
+# branch to test against
+ARG BRANCH=development
+
+# Download BookStack & install PHP deps
+RUN mkdir -p /var/www && \
+    git clone https://github.com/bookstackapp/bookstack.git --branch "$BRANCH" --single-branch /var/www/bookstack && \
+    cd /var/www/bookstack && \
+    wget https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer -O - -q | php -- --quiet --filename=composer && \
+    php composer install
+
+# Set the BookStack dir as the default working dir
+WORKDIR /var/www/bookstack
+
+# Set the default action as running php
+ENTRYPOINT ["/bin/php"]
diff --git a/dev/docker/db-testing/readme.md b/dev/docker/db-testing/readme.md
new file mode 100644
index 000000000..89892408e
--- /dev/null
+++ b/dev/docker/db-testing/readme.md
@@ -0,0 +1,32 @@
+# Database Testing Suite
+
+This docker setup is designed to run BookStack's test suite against each major database version we support
+across MySQL and MariaDB to ensure compatibility and highlight any potential issues before a release.
+This is a fairly slow and heavy process, so is designed to just be run manually before a release which
+makes changes to the database schema, or a release which makes significant changes to database queries.
+
+### Running
+
+Everything is ran via the `run.sh` script. This will:
+
+- Optionally, accept a branch of BookStack to use for testing.
+- Build the docker image from the `Dockerfile`.
+  - This will include a built-in copy of the chosen BookStack branch. 
+- Cycle through each major supported database version:
+  - Migrate and seed the database.
+  - Run the full PHP test suite.
+
+If there's a failure for a database version, the script will prompt if you'd like to continue or stop testing.
+
+This script should be ran from this `db-testing` directory:
+
+```bash
+# Enter this directory
+cd dev/docker/db-testing
+
+# Runs for the 'development' branch by default
+./run.sh
+
+# Run for a specific branch
+./run.sh v25-11
+```
diff --git a/dev/docker/db-testing/run.sh b/dev/docker/db-testing/run.sh
new file mode 100755
index 000000000..fe6ee4508
--- /dev/null
+++ b/dev/docker/db-testing/run.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+
+BRANCH=${1:-development}
+
+# Build the container with a known name
+docker build --no-cache --build-arg BRANCH="$BRANCH" -t bookstack:db-testing .
+if [ $? -eq 1 ]; then
+  echo "Failed to build app container for testing"
+  exit 1
+fi
+
+# List of database containers to test against
+containers=(
+  "mysql:8.0"
+  "mysql:8.4"
+  "mysql:9.5"
+  "mariadb:10.6"
+  "mariadb:10.11"
+  "mariadb:11.4"
+  "mariadb:11.8"
+  "mariadb:12.0"
+)
+
+# Pre-clean-up from prior runs
+docker stop bs-dbtest-db
+docker network rm bs-dbtest-net
+
+# Cycle over each database image
+for img in "${containers[@]}"; do
+  echo "Starting tests with $img..."
+  docker network create bs-dbtest-net
+  docker run -d --rm --name "bs-dbtest-db" \
+    -e MYSQL_DATABASE=bookstack-test \
+    -e MYSQL_USER=bookstack \
+    -e MYSQL_PASSWORD=bookstack \
+    -e MYSQL_ROOT_PASSWORD=password \
+	  --network=bs-dbtest-net \
+    "$img"
+  sleep 20
+  APP_RUN='docker run -it --rm --network=bs-dbtest-net -e TEST_DATABASE_URL="mysql://bookstack:bookstack@bs-dbtest-db:3306" bookstack:db-testing'
+  $APP_RUN artisan migrate --force --database=mysql_testing
+  $APP_RUN artisan db:seed --force --class=DummyContentSeeder --database=mysql_testing
+  $APP_RUN vendor/bin/phpunit
+  if [ $? -eq 0 ]; then
+    echo "$img - Success"
+  else
+    echo "$img - Error"
+    read -p "Stop script? [y/N] " ans
+    [[ $ans == [yY] ]] && exit 0
+  fi
+
+  docker stop "bs-dbtest-db"
+  docker network rm bs-dbtest-net
+done
+
diff --git a/dev/docs/development.md b/dev/docs/development.md
index ea3e692a1..418e9ead6 100644
--- a/dev/docs/development.md
+++ b/dev/docs/development.md
@@ -3,11 +3,11 @@
 All development on BookStack is currently done on the `development` branch. 
 When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
 
-* [Node.js](https://nodejs.org/en/) v20.0+
+* [Node.js](https://nodejs.org/en/) v22.0+
 
 ## Building CSS & JavaScript Assets
 
-This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
+This project uses SASS for CSS development which is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
 
 ``` bash
 # Install NPM Dependencies
@@ -113,4 +113,4 @@ docker-compose run app php vendor/bin/phpunit
 ### Debugging
 
 The docker-compose setup ships with Xdebug, which you can listen to on port 9090.
-NB : For some editors like Visual Studio Code, you might need to map your workspace folder to the /app folder within the docker container for this to work.
+NB: For some editors like Visual Studio Code, you might need to map your workspace folder to the /app folder within the docker container for this to work.
diff --git a/dev/docs/javascript-code.md b/dev/docs/javascript-code.md
index e5f491839..4de6223d8 100644
--- a/dev/docs/javascript-code.md
+++ b/dev/docs/javascript-code.md
@@ -161,3 +161,7 @@ window.$components.firstOnElement(element, name);
 There are a range of available events that are emitted as part of a public & supported API for accessing or extending JavaScript libraries & components used in the system.
 
 Details on these events can be found in the [JavaScript Public Events file](javascript-public-events.md).
+
+## WYSIWYG Editor API
+
+Details on the API for our custom-built WYSIWYG editor can be found in the [WYSIWYG JavaScript API file](./wysiwyg-js-api.md).
\ No newline at end of file
diff --git a/dev/docs/javascript-public-events.md b/dev/docs/javascript-public-events.md
index 4f68daaeb..e9ea014ef 100644
--- a/dev/docs/javascript-public-events.md
+++ b/dev/docs/javascript-public-events.md
@@ -60,7 +60,7 @@ This event is called when the markdown editor loads, post configuration but befo
 
 #### Event Data
 
-- `markdownIt` - A references to the [MarkdownIt](https://markdown-it.github.io/markdown-it/#MarkdownIt) instance used to render markdown to HTML (Just for the preview).
+- `markdownIt` - A reference to the [MarkdownIt](https://markdown-it.github.io/markdown-it/#MarkdownIt) instance used to render markdown to HTML (Just for the preview).
 - `displayEl` - The IFrame Element that wraps the HTML preview display.
 - `cmEditorView` - The CodeMirror [EditorView](https://codemirror.net/docs/ref/#view.EditorView) instance used for the markdown input editor.
 
@@ -79,7 +79,7 @@ window.addEventListener('editor-markdown::setup', event => {
 This event is called as the embedded diagrams.net drawing editor loads, to allow configuration of the diagrams.net interface.
 See [this diagrams.net page](https://www.diagrams.net/doc/faq/configure-diagram-editor) for details on the available options for the configure event.
 
-If using a custom diagrams.net instance, via the `DRAWIO` option, you will need to ensure  your DRAWIO option URL has the `configure=1` query parameter.
+If using a custom diagrams.net instance, via the `DRAWIO` option, you will need to ensure your DRAWIO option URL has the `configure=1` query parameter.
 
 #### Event Data
 
@@ -134,6 +134,47 @@ window.addEventListener('editor-tinymce::setup', event => {
 });
 ```
 
+### `editor-wysiwyg::post-init`
+
+This is called after the (new custom-built Lexical-based) WYSIWYG editor has been initialised.
+
+#### Event Data
+
+- `usage` - A string label to identify the usage type of the WYSIWYG editor in BookStack.
+- `api` - An instance to the WYSIWYG editor API, as documented in the [WYSIWYG JavaScript API file](./wysiwyg-js-api.md).
+
+##### Example
+
+The below example shows how you'd use this API to create a button, with that button added to the main toolbar of the page editor, which inserts bold "Hello!" text on press:
+
+<details>
+<summary>Show Example</summary>
+
+```javascript
+window.addEventListener('editor-wysiwyg::post-init', event => {
+    const {usage, api} = event.detail;
+    // Check that it's the page editor which is being loaded
+    if (usage !== 'page-editor') {
+        return;
+    }
+    
+    // Create a custom button which inserts bold hello text on press
+    const button = api.ui.createButton({
+        label: 'Greet',
+        action: () => {
+            api.content.insertHtml(`<strong>Hello!</strong>`);
+        }
+    });
+    
+    // Add the button to the start of the first section within the main toolbar
+    const toolbar = api.ui.getMainToolbar();
+    if (toolbar) {
+      toolbar.getSections()[0]?.addButton(button, 0);   
+    }
+});
+```
+</details>
+
 ### `library-cm6::configure-theme`
 
 This event is called whenever a CodeMirror instance is loaded, as a method to configure the theme used by CodeMirror. This applies to all CodeMirror instances including in-page code blocks, editors using in BookStack settings, and the Page markdown editor.
@@ -142,7 +183,7 @@ This event is called whenever a CodeMirror instance is loaded, as a method to co
 
 - `darkModeActive` - A boolean to indicate if the current view/page is being loaded with dark mode active.
 - `registerViewTheme(builder)` - A method that can be called to register a new view (CodeMirror UI) theme.
-  - `builder` - A function that will return  an object that will be passed into the CodeMirror [EditorView.theme()](https://codemirror.net/docs/ref/#view.EditorView^theme) function as a StyleSpec.
+  - `builder` - A function that will return an object that will be passed into the CodeMirror [EditorView.theme()](https://codemirror.net/docs/ref/#view.EditorView^theme) function as a StyleSpec.
 - `registerHighlightStyle(builder)` - A method that can be called to register a new HighlightStyle (code highlighting) theme.
   - `builder` - A function, that receives a reference to [Tag.tags](https://lezer.codemirror.net/docs/ref/#highlight.tags) and returns an array of [TagStyle](https://codemirror.net/docs/ref/#language.TagStyle) objects.
 
@@ -301,7 +342,7 @@ This event is called just after any CodeMirror instances are initialised so that
 
 ##### Example
 
-The below shows how you'd prepend some default text to all content (page) code blocks.
+The below example shows how you'd prepend some default text to all content (page) code blocks.
 
 <details>
 <summary>Show Example</summary>
@@ -318,4 +359,4 @@ window.addEventListener('library-cm6::post-init', event => {
     }
 });
 ```
-</details>
\ No newline at end of file
+</details>
diff --git a/dev/docs/php-testing.md b/dev/docs/php-testing.md
index 1bd4a414f..3d3e91fa2 100644
--- a/dev/docs/php-testing.md
+++ b/dev/docs/php-testing.md
@@ -4,7 +4,7 @@ BookStack has many test cases defined within the `tests/` directory of the app.
 
 ## Setup
 
-The application tests are mostly functional, rather than unit tests, meaning they simulate user actions and system components and therefore these require use of the database. To avoid potential conflicts within your development environment, the tests use a separate database. This is defined via a specific `mysql_testing` database connection in our configuration, and expects to use the following database access details:
+The application tests are mostly functional, rather than unit tests, meaning they simulate user actions and system components, and therefore these require use of the database. To avoid potential conflicts within your development environment, the tests use a separate database. This is defined via a specific `mysql_testing` database connection in our configuration, and expects to use the following database access details:
 
 - Host: `127.0.0.1`
 - Username: `bookstack-test`
diff --git a/dev/docs/wysiwyg-js-api.md b/dev/docs/wysiwyg-js-api.md
new file mode 100644
index 000000000..e899aa0c8
--- /dev/null
+++ b/dev/docs/wysiwyg-js-api.md
@@ -0,0 +1,127 @@
+# WYSIWYG JavaScript API
+
+**Warning: This API is currently in development and may change without notice.**
+
+Feedback is very much welcomed via this issue: https://github.com/BookStackApp/BookStack/issues/5937
+
+This document covers the JavaScript API for the (newer Lexical-based) WYSIWYG editor.
+This API is built and designed to abstract the internals of the editor away
+to provide a stable interface for performing common customizations.
+
+Only the methods and properties documented here are guaranteed to be stable **once this API
+is out of initial development**.
+Other elements may be accessible but are not designed to be used directly, and therefore may change
+without notice.
+Stable parts of the API may still change where needed, but such changes would be noted as part of BookStack update advisories.
+
+The methods shown here are documented using standard TypeScript notation.
+
+## Overview
+
+The API is provided as an object, which itself provides a number of modules
+via its properties:
+
+- `ui` - Provides methods related to the UI of the editor, like buttons and toolbars.
+- `content` - Provides methods related to the live user content being edited upon.
+
+Each of these modules, and the relevant types used within, are documented in detail below.
+
+---
+
+## UI Module
+
+This module provides methods related to the UI of the editor, like buttons and toolbars.
+
+### Methods
+
+#### createButton(options: object): EditorApiButton
+
+Creates a new button which can be used by other methods.
+This takes an option object with the following properties:
+
+- `label` - string, optional - Used for the button text if no icon provided, or the button tooltip if an icon is provided.
+- `icon` - string, optional - The icon to use for the button. Expected to be an SVG string.
+- `action` - callback, required - The action to perform when the button is clicked.
+
+The function returns an [EditorApiButton](#editorapibutton) object.
+
+**Example**
+
+```javascript
+const button = api.ui.createButton({
+    label: 'Warn',
+    icon: '<svg>...</svg>',
+    action: () => {
+        window.alert('You clicked the button!');
+    }
+});
+```
+
+### getMainToolbar(): EditorApiToolbar
+
+Get the main editor toolbar. This is typically the toolbar at the top of the editor.
+The function returns an [EditorApiToolbar](#editorapitoolbar) object, or null if no toolbar is found.
+
+**Example**
+
+```javascript
+const toolbar = api.ui.getMainToolbar();
+const sections = toolbar?.getSections() || [];
+if (sections.length > 0) {
+    sections[0].addButton(button);
+}
+```
+
+### Types
+
+These are types which may be provided from UI module methods.
+
+#### EditorApiButton
+
+Represents a button created via the `createButton` method.
+This has the following methods:
+
+- `setActive(isActive: boolean): void` - Sets whether the button should be in an active state or not (typically active buttons appear as pressed).
+
+#### EditorApiToolbar
+
+Represents a toolbar within the editor. This is a bar typically containing sets of buttons.
+This has the following methods:
+
+- `getSections(): EditorApiToolbarSection[]` - Provides the main [EditorApiToolbarSections](#editorapitoolbarsection) contained within this toolbar.
+
+#### EditorApiToolbarSection
+
+Represents a section of the main editor toolbar, which contains a set of buttons.
+This has the following methods:
+
+- `getLabel(): string` - Provides the string label of the section.
+- `addButton(button: EditorApiButton, targetIndex: number = -1): void` - Adds a button to the section.
+  - By default, this will append the button, although a target index can be provided to insert at a specific position.
+
+---
+
+## Content Module
+
+This module provides methods related to the live user content being edited within the editor.
+
+### Methods
+
+#### insertHtml(html: string, position: string = 'selection'): void
+
+Inserts the given HTML string at the given position string.
+The position, if not provided, will default to `'selection'`, replacing any existing selected content (or inserting at the selection if there's no active selection range).
+Valid position string values are: `selection`, `start` and `end`. `start` & `end` are relative to the whole editor document.
+The HTML is not assured to be added to the editor exactly as provided, since it will be parsed and serialised to fit the editor's internal known model format. Different parts of the HTML content may be handled differently depending on if it's block or inline content.
+
+The function does not return anything.
+
+**Example**
+
+```javascript
+// Basic insert at selection
+api.content.insertHtml('<p>Hello <strong>world</strong>!</p>');
+
+// Insert at the start of the editor content
+api.content.insertHtml('<p>I\'m at the start!</p>', 'start');
+```
\ No newline at end of file
diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt
index 14c0c8f49..b5fa446a4 100644
--- a/dev/licensing/js-library-licenses.txt
+++ b/dev/licensing/js-library-licenses.txt
@@ -345,7 +345,7 @@ Link: tj/co
 codemirror
 License: MIT
 License File: node_modules/codemirror/LICENSE
-Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <*******@*****.***> and others
+Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others
 Source: https://github.com/codemirror/basic-setup.git
 Link: https://github.com/codemirror/basic-setup.git
 -----------
@@ -711,13 +711,13 @@ eslint-scope
 License: BSD-2-Clause
 License File: node_modules/eslint-scope/LICENSE
 Copyright: Copyright (C) 2012-2013 Yusuke Suzuki (twitter: @Constellation) and other contributors.
-Source: eslint/js
+Source: https://github.com/eslint/js.git
 Link: https://github.com/eslint/js/blob/main/packages/eslint-scope/README.md
 -----------
 eslint-visitor-keys
 License: Apache-2.0
 License File: node_modules/eslint-visitor-keys/LICENSE
-Source: eslint/js
+Source: https://github.com/eslint/js.git
 Link: https://github.com/eslint/js/blob/main/packages/eslint-visitor-keys/README.md
 -----------
 eslint
@@ -731,7 +731,7 @@ License: BSD-2-Clause
 License File: node_modules/espree/LICENSE
 Copyright: Copyright (c) Open JS Foundation
 All rights reserved.
-Source: eslint/js
+Source: https://github.com/eslint/js.git
 Link: https://github.com/eslint/js/blob/main/packages/espree/README.md
 -----------
 esprima
@@ -1252,6 +1252,13 @@ Copyright: Copyright (c) 2019 Inspect JS
 Source: git+https://github.com/inspect-js/is-map.git
 Link: https://github.com/inspect-js/is-map#readme
 -----------
+is-negative-zero
+License: MIT
+License File: node_modules/is-negative-zero/LICENSE
+Copyright: Copyright (c) 2014 Jordan Harband
+Source: git://github.com/inspect-js/is-negative-zero.git
+Link: https://github.com/inspect-js/is-negative-zero
+-----------
 is-number-object
 License: MIT
 License File: node_modules/is-number-object/LICENSE
@@ -1751,13 +1758,6 @@ Copyright: Copyright (c) Isaac Z. Schlueter and Contributors
 Source: git://github.com/isaacs/node-lru-cache.git
 Link: git://github.com/isaacs/node-lru-cache.git
 -----------
-magic-string
-License: MIT
-License File: node_modules/magic-string/LICENSE
-Copyright: Copyright 2018 Rich Harris
-Source: https://github.com/rich-harris/magic-string
-Link: https://github.com/rich-harris/magic-string
------------
 make-dir
 License: MIT
 License File: node_modules/make-dir/license
@@ -1935,9 +1935,9 @@ Link: sindresorhus/npm-run-path
 nwsapi
 License: MIT
 License File: node_modules/nwsapi/LICENSE
-Copyright: Copyright (c) 2007-2024 Diego Perini (http://www.iport.it/)
+Copyright: Copyright (c) 2007-2025 Diego Perini (http://www.iport.it/)
 Source: git://github.com/dperini/nwsapi.git
-Link: http://javascript.nwbox.com/nwsapi/
+Link: https://javascript.nwbox.com/nwsapi/
 -----------
 object-inspect
 License: MIT
@@ -2281,18 +2281,6 @@ Copyright: Copyright (c) 2012 James Halliday
 Source: git://github.com/browserify/resolve.git
 Link: git://github.com/browserify/resolve.git
 -----------
-rollup-plugin-dts
-License: LGPL-3.0
-Source: git+https://github.com/Swatinem/rollup-plugin-dts.git
-Link: https://github.com/Swatinem/rollup-plugin-dts#readme
------------
-rollup
-License: MIT
-License File: node_modules/rollup/LICENSE.md
-Copyright: Copyright (c) 2017 [these people](https://github.com/rollup/rollup/graphs/contributors)
-Source: rollup/rollup
-Link: https://rollupjs.org/
------------
 safe-array-concat
 License: MIT
 License File: node_modules/safe-array-concat/LICENSE
@@ -2512,6 +2500,13 @@ Copyright: Copyright (c) 2016-2022 Isaac Z. Schlueter <*@***.**>, James Talmage
 Source: tapjs/stack-utils
 Link: tapjs/stack-utils
 -----------
+stop-iteration-iterator
+License: MIT
+License File: node_modules/stop-iteration-iterator/LICENSE
+Copyright: Copyright (c) 2023 Jordan Harband
+Source: git+https://github.com/ljharb/stop-iteration-iterator.git
+Link: https://github.com/ljharb/stop-iteration-iterator#readme
+-----------
 string-length
 License: MIT
 License File: node_modules/string-length/license
@@ -3011,6 +3006,13 @@ Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors
 Source: https://github.com/babel/babel.git
 Link: https://github.com/babel/babel.git
 -----------
+@babel/helper-globals
+License: MIT
+License File: node_modules/@babel/helper-globals/LICENSE
+Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors
+Source: https://github.com/babel/babel.git
+Link: https://github.com/babel/babel.git
+-----------
 @babel/helper-module-imports
 License: MIT
 License File: node_modules/@babel/helper-module-imports/LICENSE
@@ -3057,6 +3059,7 @@ Link: https://github.com/babel/babel.git
 License: MIT
 License File: node_modules/@babel/helpers/LICENSE
 Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors
+Copyright (c) 2014-present, Facebook, Inc. (ONLY ./src/helpers/regenerator* files)
 Source: https://github.com/babel/babel.git
 Link: https://babel.dev/docs/en/next/babel-helpers
 -----------
@@ -3251,7 +3254,7 @@ Link: https://github.com/codemirror/lang-javascript.git
 @codemirror/lang-json
 License: MIT
 License File: node_modules/@codemirror/lang-json/LICENSE
-Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <*******@*****.***> and others
+Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others
 Source: https://github.com/codemirror/lang-json.git
 Link: https://github.com/codemirror/lang-json.git
 -----------
@@ -3265,7 +3268,7 @@ Link: https://github.com/codemirror/lang-markdown.git
 @codemirror/lang-php
 License: MIT
 License File: node_modules/@codemirror/lang-php/LICENSE
-Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <*******@*****.***> and others
+Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> and others
 Source: https://github.com/codemirror/lang-php.git
 Link: https://github.com/codemirror/lang-php.git
 -----------
@@ -3355,13 +3358,19 @@ Link: https://github.com/eslint-community/regexpp#readme
 License: Apache-2.0
 License File: node_modules/@eslint/config-array/LICENSE
 Source: git+https://github.com/eslint/rewrite.git
-Link: https://github.com/eslint/rewrite#readme
+Link: https://github.com/eslint/rewrite/tree/main/packages/config-array#readme
+-----------
+@eslint/config-helpers
+License: Apache-2.0
+License File: node_modules/@eslint/config-helpers/LICENSE
+Source: git+https://github.com/eslint/rewrite.git
+Link: https://github.com/eslint/rewrite/tree/main/packages/config-helpers#readme
 -----------
 @eslint/core
 License: Apache-2.0
 License File: node_modules/@eslint/core/LICENSE
 Source: git+https://github.com/eslint/rewrite.git
-Link: https://github.com/eslint/rewrite#readme
+Link: https://github.com/eslint/rewrite/tree/main/packages/core#readme
 -----------
 @eslint/eslintrc
 License: MIT
@@ -3385,7 +3394,7 @@ Link: https://github.com/eslint/rewrite#readme
 License: Apache-2.0
 License File: node_modules/@eslint/plugin-kit/LICENSE
 Source: git+https://github.com/eslint/rewrite.git
-Link: https://github.com/eslint/rewrite#readme
+Link: https://github.com/eslint/rewrite/tree/main/packages/plugin-kit#readme
 -----------
 @humanfs/core
 License: Apache-2.0
@@ -3526,9 +3535,9 @@ Link: https://github.com/jestjs/jest.git
 @jridgewell/gen-mapping
 License: MIT
 License File: node_modules/@jridgewell/gen-mapping/LICENSE
-Copyright: Copyright 2022 Justin Ridgewell <**********@******.***>
-Source: https://github.com/jridgewell/gen-mapping
-Link: https://github.com/jridgewell/gen-mapping
+Copyright: Copyright 2024 Justin Ridgewell <******@*********.****>
+Source: git+https://github.com/jridgewell/sourcemaps.git
+Link: https://github.com/jridgewell/sourcemaps/tree/main/packages/gen-mapping
 -----------
 @jridgewell/resolve-uri
 License: MIT
@@ -3537,26 +3546,19 @@ Copyright: Copyright 2019 Justin Ridgewell <**********@******.***>
 Source: https://github.com/jridgewell/resolve-uri
 Link: https://github.com/jridgewell/resolve-uri
 -----------
-@jridgewell/set-array
-License: MIT
-License File: node_modules/@jridgewell/set-array/LICENSE
-Copyright: Copyright 2022 Justin Ridgewell <**********@******.***>
-Source: https://github.com/jridgewell/set-array
-Link: https://github.com/jridgewell/set-array
------------
 @jridgewell/sourcemap-codec
 License: MIT
 License File: node_modules/@jridgewell/sourcemap-codec/LICENSE
-Copyright: Copyright (c) 2015 Rich Harris
-Source: git+https://github.com/jridgewell/sourcemap-codec.git
-Link: git+https://github.com/jridgewell/sourcemap-codec.git
+Copyright: Copyright 2024 Justin Ridgewell <******@*********.****>
+Source: git+https://github.com/jridgewell/sourcemaps.git
+Link: https://github.com/jridgewell/sourcemaps/tree/main/packages/sourcemap-codec
 -----------
 @jridgewell/trace-mapping
 License: MIT
 License File: node_modules/@jridgewell/trace-mapping/LICENSE
-Copyright: Copyright 2022 Justin Ridgewell <******@*********.****>
-Source: git+https://github.com/jridgewell/trace-mapping.git
-Link: git+https://github.com/jridgewell/trace-mapping.git
+Copyright: Copyright 2024 Justin Ridgewell <******@*********.****>
+Source: git+https://github.com/jridgewell/sourcemaps.git
+Link: https://github.com/jridgewell/sourcemaps/tree/main/packages/trace-mapping
 -----------
 @lezer/common
 License: MIT
@@ -3635,13 +3637,6 @@ Copyright: Copyright (C) 2018 by Marijn Haverbeke <******@*********.******> and
 Source: https://github.com/lezer-parser/xml.git
 Link: https://github.com/lezer-parser/xml.git
 -----------
-@marijn/buildtool
-License: MIT
-License File: node_modules/@marijn/buildtool/LICENSE
-Copyright: Copyright (C) 2022 by Marijn Haverbeke <******@*********.******> and others
-Source: https://github.com/marijnh/buildtool.git
-Link: https://github.com/marijnh/buildtool.git
------------
 @marijn/find-cluster-break
 License: MIT
 License File: node_modules/@marijn/find-cluster-break/LICENSE
@@ -3832,12 +3827,26 @@ License: MIT
 Source: https://www.github.com/DefinitelyTyped/DefinitelyTyped.git
 Link: https://www.github.com/DefinitelyTyped/DefinitelyTyped.git
 -----------
-@types/mocha
+@types/linkify-it
+License: MIT
+License File: node_modules/@types/linkify-it/LICENSE
+Copyright: Copyright (c) Microsoft Corporation.
+Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git
+Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/linkify-it
+-----------
+@types/markdown-it
+License: MIT
+License File: node_modules/@types/markdown-it/LICENSE
+Copyright: Copyright (c) Microsoft Corporation.
+Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git
+Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/markdown-it
+-----------
+@types/mdurl
 License: MIT
-License File: node_modules/@types/mocha/LICENSE
+License File: node_modules/@types/mdurl/LICENSE
 Copyright: Copyright (c) Microsoft Corporation.
 Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git
-Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/mocha
+Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/mdurl
 -----------
 @types/node
 License: MIT
diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt
index 86e40a055..276f39230 100644
--- a/dev/licensing/php-library-licenses.txt
+++ b/dev/licensing/php-library-licenses.txt
@@ -13,7 +13,7 @@ Link: http://aws.amazon.com/sdkforphp
 bacon/bacon-qr-code
 License: BSD-2-Clause
 License File: vendor/bacon/bacon-qr-code/LICENSE
-Copyright: Copyright (c) 2017, Ben Scholzen 'DASPRiD'
+Copyright: Copyright (c) 2017-present, Ben Scholzen 'DASPRiD'
 All rights reserved.
 Source: https://github.com/Bacon/BaconQrCode.git
 Link: https://github.com/Bacon/BaconQrCode
@@ -465,9 +465,9 @@ Link: https://github.com/php-fig/simple-cache.git
 psy/psysh
 License: MIT
 License File: vendor/psy/psysh/LICENSE
-Copyright: Copyright (c) 2012-2023 Justin Hileman
+Copyright: Copyright (c) 2012-2025 Justin Hileman
 Source: https://github.com/bobthecow/psysh.git
-Link: http://psysh.org
+Link: https://psysh.org
 -----------
 ralouphie/getallheaders
 License: MIT
@@ -486,7 +486,7 @@ Link: https://github.com/ramsey/collection.git
 ramsey/uuid
 License: MIT
 License File: vendor/ramsey/uuid/LICENSE
-Copyright: Copyright (c) 2012-2023 Ben Ramsey <***@*********.***>
+Copyright: Copyright (c) 2012-2025 Ben Ramsey <***@*********.***>
 Source: https://github.com/ramsey/uuid.git
 Link: https://github.com/ramsey/uuid.git
 -----------
@@ -543,13 +543,6 @@ Copyright: Copyright (c) 2024 Nathan Herald, Rohland de Charmoy, Dan Brown
 Source: https://codeberg.org/danb/HtmlDiff
 Link: https://codeberg.org/danb/HtmlDiff
 -----------
-ssddanbrown/symfony-mailer
-License: MIT
-License File: vendor/ssddanbrown/symfony-mailer/LICENSE
-Copyright: Copyright (c) 2019-present Fabien Potencier
-Source: https://github.com/ssddanbrown/symfony-mailer.git
-Link: https://symfony.com
------------
 symfony/clock
 License: MIT
 License File: vendor/symfony/clock/LICENSE
@@ -620,6 +613,13 @@ Copyright: Copyright (c) 2004-present Fabien Potencier
 Source: https://github.com/symfony/http-kernel.git
 Link: https://symfony.com
 -----------
+symfony/mailer
+License: MIT
+License File: vendor/symfony/mailer/LICENSE
+Copyright: Copyright (c) 2019-present Fabien Potencier
+Source: https://github.com/symfony/mailer.git
+Link: https://symfony.com
+-----------
 symfony/mime
 License: MIT
 License File: vendor/symfony/mime/LICENSE
@@ -676,6 +676,20 @@ Copyright: Copyright (c) 2022-present Fabien Potencier
 Source: https://github.com/symfony/polyfill-php83.git
 Link: https://symfony.com
 -----------
+symfony/polyfill-php84
+License: MIT
+License File: vendor/symfony/polyfill-php84/LICENSE
+Copyright: Copyright (c) 2024-present Fabien Potencier
+Source: https://github.com/symfony/polyfill-php84.git
+Link: https://symfony.com
+-----------
+symfony/polyfill-php85
+License: MIT
+License File: vendor/symfony/polyfill-php85/LICENSE
+Copyright: Copyright (c) 2025-present Fabien Potencier
+Source: https://github.com/symfony/polyfill-php85.git
+Link: https://symfony.com
+-----------
 symfony/polyfill-uuid
 License: MIT
 License File: vendor/symfony/polyfill-uuid/LICENSE
@@ -759,10 +773,3 @@ License File: vendor/voku/portable-ascii/LICENSE.txt
 Copyright: Copyright (C) 2019 Lars Moelleken
 Source: https://github.com/voku/portable-ascii.git
 Link: https://github.com/voku/portable-ascii
------------
-webmozart/assert
-License: MIT
-License File: vendor/webmozart/assert/LICENSE
-Copyright: Copyright (c) 2014 Bernhard Schussek
-Source: https://github.com/webmozarts/assert.git
-Link: https://github.com/webmozarts/assert.git
diff --git a/lang/ar/common.php b/lang/ar/common.php
index 07576a250..44d057d7e 100644
--- a/lang/ar/common.php
+++ b/lang/ar/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'إنشاء',
     'update' => 'تحديث',
     'edit' => 'تعديل',
+    'archive' => 'أرشف',
+    'unarchive' => 'إلغاء الأرشفة',
     'sort' => 'سرد',
     'move' => 'نقل',
     'copy' => 'نسخ',
diff --git a/lang/ar/editor.php b/lang/ar/editor.php
index c656ba5a0..b3a8fd3c7 100644
--- a/lang/ar/editor.php
+++ b/lang/ar/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'نص مرتفع',
     'subscript' => 'نص منخفض',
     'text_color' => 'لون النص',
+    'highlight_color' => 'لون التمييز',
     'custom_color' => 'لون مخصص',
     'remove_color' => 'إزالة اللون',
     'background_color' => 'لون الخلفية',
diff --git a/lang/ar/entities.php b/lang/ar/entities.php
index aea8bf1e5..8b2882c60 100644
--- a/lang/ar/entities.php
+++ b/lang/ar/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'سيؤدي هذا إلى حذف مِلَفّ الاستيراد المضغوط ZIP، ولا يمكن التراجع عنه.',
     'import_errors' => 'أخطاء الاستيراد',
     'import_errors_desc' => 'حدثت الأخطاء التالية خلال محاولة الاستيراد:',
+    'breadcrumb_siblings_for_page' => 'تصفح أشقاء هذه الصفحة',
+    'breadcrumb_siblings_for_chapter' => 'تصفح أشقاء هذا الفصل',
+    'breadcrumb_siblings_for_book' => 'تصفح أشقاء هذا الكتاب',
+    'breadcrumb_siblings_for_bookshelf' => 'تصفح أشقاء هذا الرف',
 
     // Permissions and restrictions
     'permissions' => 'الأذونات',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(محتوى مستقر)',
     'pages_edit_switch_to_wysiwyg' => 'التبديل إلى محرر ما تراه هو ما تحصل عليه -WYSIWYG-',
     'pages_edit_switch_to_new_wysiwyg' => 'التبديل إلى محرر ما تراه هو ما تحصل عليه الجديد -new WYSIWYG-',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(في اختبار ألف)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(في الاختبار التجريبي)',
     'pages_edit_set_changelog' => 'تثبيت سجل التعديل',
     'pages_edit_enter_changelog_desc' => 'ضع وصف مختصر للتعديلات التي تمت',
     'pages_edit_enter_changelog' => 'أدخل سجل التعديل',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'إدخال رسمة',
     'pages_md_show_preview' => 'عرض المعاينة',
     'pages_md_sync_scroll' => 'مزامنة معاينة التمرير',
+    'pages_md_plain_editor' => 'محرر النصوص العادي',
     'pages_drawing_unsaved' => 'تم العثور على رسم غير محفوظ',
     'pages_drawing_unsaved_confirm' => 'تم العثور على بيانات رسم غير محفوظة من محاولة حفظ رسم سابقة فاشلة. هل ترغب في استعادة هذا الرسم غير المحفوظ ومواصلة تحريره؟',
     'pages_not_in_chapter' => 'صفحة ليست في فصل',
@@ -392,8 +397,11 @@ return [
     'comment' => 'تعليق',
     'comments' => 'تعليقات',
     'comment_add' => 'إضافة تعليق',
+    'comment_none' => 'لا توجد تعليقات لعرضها',
     'comment_placeholder' => 'ضع تعليقاً هنا',
-    'comment_count' => '{0} لا توجد تعليقات|{1} تعليق واحد|{2} تعليقان[3,*] :count تعليقات',
+    'comment_thread_count' => ':count تعليقات| :count تعليقات',
+    'comment_archived_count' => ':count مؤرشف',
+    'comment_archived_threads' => 'المواضيع المؤرشفة',
     'comment_save' => 'حفظ التعليق',
     'comment_new' => 'تعليق جديد',
     'comment_created' => 'تم التعليق :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'تم حذف التعليق',
     'comment_created_success' => 'تمت إضافة التعليق',
     'comment_updated_success' => 'تم تحديث التعليق',
+    'comment_archive_success' => 'تم أرشفة التعليق',
+    'comment_unarchive_success' => 'تعليق غير مؤرشف',
+    'comment_view' => 'عرض التعليق',
+    'comment_jump_to_thread' => 'انتقل إلى الموضوع',
     'comment_delete_confirm' => 'تأكيد حذف التعليق؟',
     'comment_in_reply_to' => 'رداً على :commentId',
+    'comment_reference' => 'المرجع',
+    'comment_reference_outdated' => '(قديمة)',
     'comment_editor_explain' => 'هذه هي التعليقات المُضافة على هذه الصفحة. يُمكنك إضافة التعليقات وإدارتها عند عرض الصفحة المحفوظة.',
 
     // Revision
diff --git a/lang/ar/settings.php b/lang/ar/settings.php
index 8e615fc9c..9006ac227 100644
--- a/lang/ar/settings.php
+++ b/lang/ar/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/bg/common.php b/lang/bg/common.php
index 30298e639..ba74ea5b2 100644
--- a/lang/bg/common.php
+++ b/lang/bg/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Създаване',
     'update' => 'Обновяване',
     'edit' => 'Редактиране',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Сортиране',
     'move' => 'Преместване',
     'copy' => 'Копиране',
diff --git a/lang/bg/editor.php b/lang/bg/editor.php
index e01206e4a..a75128953 100644
--- a/lang/bg/editor.php
+++ b/lang/bg/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Горен индекс',
     'subscript' => 'Долен индекс',
     'text_color' => 'Цвят на текста',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Цвят по избор',
     'remove_color' => 'Премахване на цвят',
     'background_color' => 'Фонов цвят',
diff --git a/lang/bg/entities.php b/lang/bg/entities.php
index c4a62eb42..42cdad801 100644
--- a/lang/bg/entities.php
+++ b/lang/bg/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Права',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Задайте регистър на промените',
     'pages_edit_enter_changelog_desc' => 'Въведете кратко резюме на промените, които сте създали',
     'pages_edit_enter_changelog' => 'Въведи регистър на промените',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Вмъкни рисунка',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Страницата не принадлежи в никоя глава',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Коментирай',
     'comments' => 'Коментари',
     'comment_add' => 'Добавяне на коментар',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Напишете коментар',
-    'comment_count' => '{0} Няма коментари|{1} 1 коментар|[2,*] :count коментара',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Запази коментар',
     'comment_new' => 'Нов коментар',
     'comment_created' => 'коментирано :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Коментарът е изтрит',
     'comment_created_success' => 'Коментарът е добавен',
     'comment_updated_success' => 'Коментарът е обновен',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Наистина ли искате да изтриете този коментар?',
     'comment_in_reply_to' => 'В отговор на :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/bg/settings.php b/lang/bg/settings.php
index b5ed1e4a7..6dd27d74a 100644
--- a/lang/bg/settings.php
+++ b/lang/bg/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/bn/common.php b/lang/bn/common.php
index 8f425abee..baf20426f 100644
--- a/lang/bn/common.php
+++ b/lang/bn/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'তৈরী করুন',
     'update' => 'হালনাগাদ করুন',
     'edit' => 'সম্পাদন করুন',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'ক্রমান্বয় করুন',
     'move' => 'স্থানান্তর করুন',
     'copy' => 'অনুলিপি করুন',
diff --git a/lang/bn/editor.php b/lang/bn/editor.php
index 50b4ce4d8..d8476eeae 100644
--- a/lang/bn/editor.php
+++ b/lang/bn/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superscript',
     'subscript' => 'Subscript',
     'text_color' => 'Text color',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Custom color',
     'remove_color' => 'Remove color',
     'background_color' => 'Background color',
diff --git a/lang/bn/entities.php b/lang/bn/entities.php
index a74785eaa..74c50be3b 100644
--- a/lang/bn/entities.php
+++ b/lang/bn/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Set Changelog',
     'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
     'pages_edit_enter_changelog' => 'Enter Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insert Drawing',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Page is not in a chapter',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/bn/settings.php b/lang/bn/settings.php
index 8e584abea..04aca4f2e 100644
--- a/lang/bn/settings.php
+++ b/lang/bn/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/bs/common.php b/lang/bs/common.php
index 4e4631a0d..c2f1fda0d 100644
--- a/lang/bs/common.php
+++ b/lang/bs/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Kreiraj',
     'update' => 'Ažuriraj',
     'edit' => 'Uredi',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sortiraj',
     'move' => 'Pomjeri',
     'copy' => 'Kopiraj',
diff --git a/lang/bs/editor.php b/lang/bs/editor.php
index 752c6f3f7..0d250e9a7 100644
--- a/lang/bs/editor.php
+++ b/lang/bs/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superscript',
     'subscript' => 'Subscript',
     'text_color' => 'Text color',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Custom color',
     'remove_color' => 'Remove color',
     'background_color' => 'Background color',
diff --git a/lang/bs/entities.php b/lang/bs/entities.php
index 9bcbdb0f5..f671e1b45 100644
--- a/lang/bs/entities.php
+++ b/lang/bs/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Dozvole',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Set Changelog',
     'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
     'pages_edit_enter_changelog' => 'Enter Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insert Drawing',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Page is not in a chapter',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/bs/settings.php b/lang/bs/settings.php
index 82a4ade5d..81c2c0a93 100644
--- a/lang/bs/settings.php
+++ b/lang/bs/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/ca/activities.php b/lang/ca/activities.php
index 8decaa739..4894c279a 100644
--- a/lang/ca/activities.php
+++ b/lang/ca/activities.php
@@ -18,35 +18,35 @@ return [
     'page_move_notification'      => 'S’ha mogut la pàgina',
 
     // Chapters
-    'chapter_create'              => 'ha creat el capítol',
+    'chapter_create'              => 'S\'ha creat el capítol',
     'chapter_create_notification' => 'S’ha creat el capítol',
     'chapter_update'              => 'ha actualitzat el capítol',
     'chapter_update_notification' => 'S’ha actualitzat el capítol',
     'chapter_delete'              => 'ha suprimit el capítol',
     'chapter_delete_notification' => 'S’ha suprimit el capítol',
-    'chapter_move'                => 'ha mogut el capítol',
+    'chapter_move'                => 's\'ha mogut el capítol',
     'chapter_move_notification' => 'S’ha mogut el capítol',
 
     // Books
-    'book_create'                 => 'ha creat el llibre',
+    'book_create'                 => 'llibre creat',
     'book_create_notification'    => 'S’ha creat el llibre',
     'book_create_from_chapter'              => 'ha convertit el capítol a llibre',
     'book_create_from_chapter_notification' => 'S’ha convertit el capítol a llibre',
-    'book_update'                 => 'ha actualitzat el llibre',
+    'book_update'                 => 'llibre actualitzat',
     'book_update_notification'    => 'S’ha actualitzat el llibre',
-    'book_delete'                 => 'ha suprimit el llibre',
+    'book_delete'                 => 'llibre suprimit',
     'book_delete_notification'    => 'S’ha suprimit el llibre',
-    'book_sort'                   => 'ha ordenat el llibre',
+    'book_sort'                   => 'llibre ordenat',
     'book_sort_notification'      => 'S’ha tornat a ordenar el llibre',
 
     // Bookshelves
     'bookshelf_create'            => 'ha creat el prestatge',
     'bookshelf_create_notification'    => 'S’ha creat el prestatge',
-    'bookshelf_create_from_book'    => 'ha convertit el llibre a prestatge',
+    'bookshelf_create_from_book'    => 'llibre convertit a prestatge',
     'bookshelf_create_from_book_notification'    => 'S’ha convertit el llibre a prestatge',
-    'bookshelf_update'                 => 'ha actualitzat el prestatge',
+    'bookshelf_update'                 => 'prestatge actualitzat',
     'bookshelf_update_notification'    => 'S’ha actualitzat el prestatge',
-    'bookshelf_delete'                 => 'ha suprimit el prestatge',
+    'bookshelf_delete'                 => 'prestatge suprimit',
     'bookshelf_delete_notification'    => 'S’ha suprimit el prestatge',
 
     // Revisions
@@ -85,12 +85,12 @@ return [
     'webhook_delete_notification' => 'S’ha suprimit el webhook',
 
     // Imports
-    'import_create' => 'created import',
-    'import_create_notification' => 'Import successfully uploaded',
-    'import_run' => 'updated import',
-    'import_run_notification' => 'Content successfully imported',
-    'import_delete' => 'deleted import',
-    'import_delete_notification' => 'Import successfully deleted',
+    'import_create' => 'importació creada',
+    'import_create_notification' => 'L\'importació s\'ha carregat correctament',
+    'import_run' => 'importació actualitzada',
+    'import_run_notification' => 'Contingut importat correctament',
+    'import_delete' => 'importació eliminada',
+    'import_delete_notification' => 'Importació eliminada correctament',
 
     // Users
     'user_create' => 'ha creat l’usuari',
@@ -128,12 +128,12 @@ return [
     'comment_delete'              => 'ha suprimit un comentari',
 
     // Sort Rules
-    'sort_rule_create' => 'created sort rule',
-    'sort_rule_create_notification' => 'Sort rule successfully created',
-    'sort_rule_update' => 'updated sort rule',
-    'sort_rule_update_notification' => 'Sort rule successfully updated',
-    'sort_rule_delete' => 'deleted sort rule',
-    'sort_rule_delete_notification' => 'Sort rule successfully deleted',
+    'sort_rule_create' => 'crear regla d\'ordenació',
+    'sort_rule_create_notification' => 'Regla d\'ordenació creada correctament',
+    'sort_rule_update' => 'regla d\'ordenació actualitzada',
+    'sort_rule_update_notification' => 'Regla d\'ordenació actualitzada correctament',
+    'sort_rule_delete' => 'regla d\'ordenació eliminada',
+    'sort_rule_delete_notification' => 'Regla d\'ordenació eliminada correctament',
 
     // Other
     'permissions_update'          => 'ha actualitzat els permisos',
diff --git a/lang/ca/common.php b/lang/ca/common.php
index f4ad19230..8976edaa1 100644
--- a/lang/ca/common.php
+++ b/lang/ca/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Crea',
     'update' => 'Actualitza',
     'edit' => 'Edita',
+    'archive' => 'Arxivar',
+    'unarchive' => 'Desarxivar',
     'sort' => 'Ordena',
     'move' => 'Mou',
     'copy' => 'Copia',
@@ -109,5 +111,5 @@ return [
     'terms_of_service' => 'Condicions del servei',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Buscar :appName',
 ];
diff --git a/lang/ca/editor.php b/lang/ca/editor.php
index 331e59631..e19d82f8f 100644
--- a/lang/ca/editor.php
+++ b/lang/ca/editor.php
@@ -13,7 +13,7 @@ return [
     'cancel' => 'Cancel·la',
     'save' => 'Desa',
     'close' => 'Tanca',
-    'apply' => 'Apply',
+    'apply' => 'Aplicar',
     'undo' => 'Desfés',
     'redo' => 'Refés',
     'left' => 'Esquerra',
@@ -25,7 +25,7 @@ return [
     'width' => 'Amplada',
     'height' => 'Altura',
     'More' => 'Més',
-    'select' => 'Selecciona…',
+    'select' => 'Selecciona …',
 
     // Toolbar
     'formats' => 'Formats',
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superíndex',
     'subscript' => 'Subíndex',
     'text_color' => 'Color del text',
+    'highlight_color' => 'Color ressaltat',
     'custom_color' => 'Color personalitzat',
     'remove_color' => 'Elimina el color',
     'background_color' => 'Color de fons',
@@ -148,7 +149,7 @@ return [
     'url' => 'URL',
     'text_to_display' => 'Text per a mostrar',
     'title' => 'Títol',
-    'browse_links' => 'Browse links',
+    'browse_links' => 'Explorar enllaços',
     'open_link' => 'Obre l’enllaç',
     'open_link_in' => 'Obre l’enllaç…',
     'open_link_current' => 'A la finestra actual',
@@ -165,8 +166,8 @@ return [
     'about' => 'Quant a l’Editor',
     'about_title' => 'Quant a l’Editor WYSIWYG',
     'editor_license' => 'Llicència i copyright de l’Editor',
-    'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
-    'editor_lexical_license_link' => 'Full license details can be found here.',
+    'editor_lexical_license' => 'Aquest editor està construït com una bifurcació de :lexicalLink i es distribueix sota la llicència MIT.',
+    'editor_lexical_license_link' => 'Tots els detalls complets de la llicència es poden trobar aquí.',
     'editor_tiny_license' => 'Aquest editor s’ha creat amb :tinyLink que es proporciona amb la llicència MIT.',
     'editor_tiny_license_link' => 'Detalls de la llicència i el copyright de TinyMCE.',
     'save_continue' => 'Desa la pàgina i continua',
diff --git a/lang/ca/entities.php b/lang/ca/entities.php
index e1939babd..108a38c2c 100644
--- a/lang/ca/entities.php
+++ b/lang/ca/entities.php
@@ -39,30 +39,34 @@ return [
     'export_pdf' => 'Fitxer PDF',
     'export_text' => 'Fitxer de text sense format',
     'export_md' => 'Fitxer Markdown',
-    'export_zip' => 'Portable ZIP',
-    'default_template' => 'Default Page Template',
-    'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
-    'default_template_select' => 'Select a template page',
-    'import' => 'Import',
-    'import_validate' => 'Validate Import',
-    'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
-    'import_zip_select' => 'Select ZIP file to upload',
-    'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
-    'import_pending' => 'Pending Imports',
-    'import_pending_none' => 'No imports have been started.',
-    'import_continue' => 'Continue Import',
-    'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
-    'import_details' => 'Import Details',
-    'import_run' => 'Run Import',
-    'import_size' => ':size Import ZIP Size',
-    'import_uploaded_at' => 'Uploaded :relativeTime',
-    'import_uploaded_by' => 'Uploaded by',
-    'import_location' => 'Import Location',
-    'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
-    'import_delete_confirm' => 'Are you sure you want to delete this import?',
-    'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
-    'import_errors' => 'Import Errors',
-    'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'export_zip' => 'ZIP portable',
+    'default_template' => 'Plantilla de pàgina per defecte',
+    'default_template_explain' => 'Assigna una plantilla de pàgina que s\'utilitzarà com a contingut predeterminat per a totes les pàgines creades en aquest element. Tingues en compte que això només s\'utilitzarà si el creador de pàgines té accés a la plantilla de pàgina triada.',
+    'default_template_select' => 'Seleccioneu una plantilla de pàgina',
+    'import' => 'Importar',
+    'import_validate' => 'Validar importació',
+    'import_desc' => 'Importar llibres, capítols i pàgines utilitzant una exportació ZIP portable de la mateixa o una altra instància. Selecciona un arxiu ZIP per continuar. Després que l\'arxiu s\'hagi penjat i validat, podrà configurar i confirmar l\'importació en la següent vista.',
+    'import_zip_select' => 'Seleccioneu un fitxer ZIP per pujar',
+    'import_zip_validation_errors' => 'S\'han detectat errors al validar l\'arxiu ZIP proporcionat:',
+    'import_pending' => 'Importació pendent',
+    'import_pending_none' => 'No s\'han iniciat les importacions.',
+    'import_continue' => 'Continuar importació',
+    'import_continue_desc' => 'Revisa el contingut que s\'ha d\'importar de l\'arxiu ZIP penjat. Quan estigui llest, executa l\'importació per afegir el seu contingut al sistema. L\'arxiu d\'importació ZIP penjat s\'eliminarà automàticament al finalitzar l\'importació correctament.',
+    'import_details' => 'Detalls d\'importació',
+    'import_run' => 'Executar importació',
+    'import_size' => ':size Mida de l\'arxiu ZIP',
+    'import_uploaded_at' => 'Penjat :relativeTime',
+    'import_uploaded_by' => 'Actualitzat per',
+    'import_location' => 'Importar ubicació',
+    'import_location_desc' => 'Selecciona una ubicació de destí pel contingut importat. Necessitarà els permisos pertinents per crear-lo dins de la ubicació triada.',
+    'import_delete_confirm' => 'Esteu segur que voleu suplir aquesta importació?',
+    'import_delete_desc' => 'Això eliminarà l\'arxiu ZIP d\'importació penjat i no es pot desfer.',
+    'import_errors' => 'Importar errors',
+    'import_errors_desc' => 'S\'han produït els següents errors durant l\'intent d\'importació:',
+    'breadcrumb_siblings_for_page' => 'Navegar entre pàgines del mateix nivell',
+    'breadcrumb_siblings_for_chapter' => 'Navegar entre capítols del mateix nivell',
+    'breadcrumb_siblings_for_book' => 'Navegar entre llibres del mateix nivell',
+    'breadcrumb_siblings_for_bookshelf' => 'Navegar entre llibreries del mateix nivell',
 
     // Permissions and restrictions
     'permissions' => 'Permisos',
@@ -166,9 +170,9 @@ return [
     'books_search_this' => 'Cerca en aquest llibre',
     'books_navigation' => 'Navegació del llibre',
     'books_sort' => 'Ordena el contingut d’un llibre',
-    'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.',
-    'books_sort_auto_sort' => 'Auto Sort Option',
-    'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName',
+    'books_sort_desc' => 'Mou capítols i pàgines dins d\'un llibre per reorganitzar el seu contingut. Es poden afegir altres llibres que permetin moure fàcilment capítols i pàgines entre llibres. De manera opcional, es poden establir regles d\'ordenació automàtica per ordenar automàticament el contingut d\'aquest llibre quan hi hagi canvis.',
+    'books_sort_auto_sort' => 'Opció d\'ordenació automàtica',
+    'books_sort_auto_sort_active' => 'Opció d\'ordenació activa :sortName',
     'books_sort_named' => 'Ordena el llibre &laquo;:bookName&raquo;',
     'books_sort_name' => 'Ordena pel nom',
     'books_sort_created' => 'Ordena per la data de creació',
@@ -230,7 +234,7 @@ return [
     'pages_delete_draft' => 'Suprimeix l’esborrany de pàgina',
     'pages_delete_success' => 'S’ha suprimit la pàgina',
     'pages_delete_draft_success' => 'S’ha suprimit l’esborrany de pàgina',
-    'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.',
+    'pages_delete_warning_template' => 'Aquesta pàgina està en ús com a plantilla de pàgina predeterminada de llibre o capítol. Aquests llibres o capítols ja no tindran una plantilla de pàgina predeterminada assignada després d\'eliminar aquesta pàgina.',
     'pages_delete_confirm' => 'Esteu segur que voleu suprimir aquesta pàgina?',
     'pages_delete_draft_confirm' => 'Esteu segur que voleu suprimir aquest esborrany de pàgina?',
     'pages_editing_named' => 'Edició de la pàgina &laquo;:pageName&raquo;',
@@ -247,8 +251,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Contingut net)',
     'pages_edit_switch_to_markdown_stable' => '(Contingut estable)',
     'pages_edit_switch_to_wysiwyg' => 'Canvia a l’editor WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Canviar al nou editor WYSIWYG',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(En Beta Test)',
     'pages_edit_set_changelog' => 'Registre de canvis',
     'pages_edit_enter_changelog_desc' => 'Introduïu una descripció breu dels canvis que heu fet',
     'pages_edit_enter_changelog' => 'Registra un canvi',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insereix un dibuix',
     'pages_md_show_preview' => 'Mostra la visualització prèvia',
     'pages_md_sync_scroll' => 'Sincronitza el desplaçament de la visualització prèvia',
+    'pages_md_plain_editor' => 'Editor de text pla',
     'pages_drawing_unsaved' => 'S’ha trobat un dibuix sense desar',
     'pages_drawing_unsaved_confirm' => 'S’han trobat dades d’un dibuix d’un intent anterior de desar un dibuix. Voleu restaurar aquest dibuix no desat per a reprendre’n l’edició?',
     'pages_not_in_chapter' => 'La pàgina no és un capítol',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comentari',
     'comments' => 'Comentaris',
     'comment_add' => 'Afegeix un comentari',
+    'comment_none' => 'No hi ha comentaris per mostrar',
     'comment_placeholder' => 'Deixa un comentari aquí',
-    'comment_count' => '{0} No hi ha cap comentari|{1} Hi ha 1 comentari|[2,*] Hi ha :count comentaris',
+    'comment_thread_count' => ':count fil de comentaris|:count fils de comentaris',
+    'comment_archived_count' => ':count Arxivats',
+    'comment_archived_threads' => 'Fils arxivats',
     'comment_save' => 'Desa el comentari',
     'comment_new' => 'Crea un comentari',
     'comment_created' => 'ha comentat :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'S’ha suprimit el comentari',
     'comment_created_success' => 'S’ha afegit un comentari',
     'comment_updated_success' => 'S’ha actualitzat un comentari',
+    'comment_archive_success' => 'Comentari arxivat',
+    'comment_unarchive_success' => 'Comentari desarxivat',
+    'comment_view' => 'Mostra el comentari',
+    'comment_jump_to_thread' => 'Anar al fil',
     'comment_delete_confirm' => 'Esteu segur que voleu suprimir aquest comentari?',
     'comment_in_reply_to' => 'En resposta a :commentId',
+    'comment_reference' => 'Referència',
+    'comment_reference_outdated' => '(Obsolet)',
     'comment_editor_explain' => 'Vet aquí els comentaris que s’han fet en aquesta pàgina. Els comentaris es poden fer i gestionar quan es visualitza la pàgina desada.',
 
     // Revision
diff --git a/lang/ca/errors.php b/lang/ca/errors.php
index 76fb293da..6f3784c2c 100644
--- a/lang/ca/errors.php
+++ b/lang/ca/errors.php
@@ -6,17 +6,17 @@ return [
 
     // Permissions
     'permission' => 'No teniu permís per a accedir a la pàgina sol·licitada.',
-    'permissionJson' => 'No teniu permís per a fer l’acció sol·licitada.',
+    'permissionJson' => 'No teniu permís per a executar l’acció sol·licitada.',
 
     // Auth
     'error_user_exists_different_creds' => 'Ja existeix un usuari amb el correu electrònic :email però amb unes credencials diferents.',
-    'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details',
-    'email_already_confirmed' => 'Ja s’ha confirmat el correu electrònic. Proveu d’iniciar sessió.',
+    'auth_pre_register_theme_prevention' => 'El compte d\'usuari no s\'ha pogut registrar amb els detalls proporcionats',
+    'email_already_confirmed' => 'L’adreça electrònica ja està confirmada. Proveu d’iniciar la sessió.',
     'email_confirmation_invalid' => 'Aquest testimoni de confirmació no és vàlid o ja s’ha utilitzat. Proveu de tornar-vos a registrar.',
     'email_confirmation_expired' => 'Aquest testimoni de confirmació ha caducat. S’ha enviat un altre correu electrònic de confirmació.',
-    'email_confirmation_awaiting' => 'Cal confirmar l’adreça electrònica del compte que utilitzeu.',
+    'email_confirmation_awaiting' => 'Cal confirmar l’adreça electrònica del compte que utilitzeu',
     'ldap_fail_anonymous' => 'L’accés LDAP anònim ha fallat',
-    'ldap_fail_authed' => 'L’accés LDAP amb el nom distintiu i la contrasenya proporcionades',
+    'ldap_fail_authed' => 'L’accés LDAP amb el nom distintiu i la contrasenya proporcionats ha fallat',
     'ldap_extension_not_installed' => 'L’extensió PHP de l’LDAP no està instal·lada',
     'ldap_cannot_connect' => 'No s’ha pogut connectar amb el servidor LDAP perquè la connexió inicial ha fallat',
     'saml_already_logged_in' => 'Ja heu iniciat sessió',
@@ -29,15 +29,15 @@ return [
     'social_no_action_defined' => 'No s’ha definit cap acció',
     'social_login_bad_response' => "S’ha produït un error en l’inici de sessió amb :socialAccount: \n:error",
     'social_account_in_use' => 'Aquest compte de :socialAccount ja s’està utilitzant. Proveu d’iniciar sessió amb :socialAccount.',
-    'social_account_email_in_use' => 'L’adreça electrònica :email ja s’està utilitzant. Si ja teniu uns compte podeu connectar-hi el vostre compte de :socialAccount des de la configuració del vostre perfil.',
+    'social_account_email_in_use' => 'L’adreça electrònica :email ja està en ús. Si ja teniu un compte, podeu connectar-hi el vostre compte de :socialAccount a la configuració del vostre perfil.',
     'social_account_existing' => 'Aquest compte de :socialAccount ja està associat al vostre perfil.',
     'social_account_already_used_existing' => 'Aquest compte de :socialAccount ja està associat a un altre usuari.',
     'social_account_not_used' => 'Aquest compte de :socialAccount no està associat a cap usuari. Associeu-lo a la configuració del vostre perfil. ',
     'social_account_register_instructions' => 'Si encara no teniu un compte, podeu registrar-vos amb l’opció :socialAccount.',
     'social_driver_not_found' => 'No s’ha trobat el controlador social',
     'social_driver_not_configured' => 'La configuració de :socialAccount no és correcta.',
-    'invite_token_expired' => 'Aquest enllaç d’invitació ha caducat. Proveu de reinicialitzar la contrasenya.',
-    'login_user_not_found' => 'A user for this action could not be found.',
+    'invite_token_expired' => 'Aquest enllaç d’invitació ha caducat. Podeu provar de restablir la contrasenya del vostre compte.',
+    'login_user_not_found' => 'No s\'ha pogut trobar un usuari per aquesta acció.',
 
     // System
     'path_not_writable' => 'No s’ha pogut pujar a :filePath. Assegureu-vos que teniu permisos d’escriptura al servidor.',
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'No podeu suprimir l’administrador únic.',
     'users_cannot_delete_guest' => 'No podeu suprimir l’usuari convidat.',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'No s\'ha pogut crear l\'usuari, ja que no s\'ha pogut enviar el correu d\'invitació',
 
     // Roles
     'role_cannot_be_edited' => 'No es pot editar aquest rol.',
@@ -106,16 +106,16 @@ return [
     'back_soon' => 'Aviat ho arreglarem.',
 
     // Import
-    'import_zip_cant_read' => 'Could not read ZIP file.',
-    'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
-    'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
-    'import_validation_failed' => 'Import ZIP failed to validate with errors:',
-    'import_zip_failed_notification' => 'Failed to import ZIP file.',
-    'import_perms_books' => 'You are lacking the required permissions to create books.',
-    'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
-    'import_perms_pages' => 'You are lacking the required permissions to create pages.',
-    'import_perms_images' => 'You are lacking the required permissions to create images.',
-    'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
+    'import_zip_cant_read' => 'No es pot llegir el fitxer ZIP.',
+    'import_zip_cant_decode_data' => 'No s\'ha pogut trobar i descodificar el fitxer data.json en el fitxer ZIP.',
+    'import_zip_no_data' => 'Les dades del fitxer ZIP no contenen cap llibre, capítol o contingut de pàgina.',
+    'import_validation_failed' => 'Error en validar la importació del ZIP amb els errors:',
+    'import_zip_failed_notification' => 'Error en importar l\'arxiu ZIP.',
+    'import_perms_books' => 'Li falten els permisos necessaris per crear llibres.',
+    'import_perms_chapters' => 'Li falten els permisos necessaris per crear capítols.',
+    'import_perms_pages' => 'Li falten els permisos necessaris per crear pàgines.',
+    'import_perms_images' => 'Li falten els permisos necessaris per crear imatges.',
+    'import_perms_attachments' => 'Li falten els permisos necessaris per crear adjunts.',
 
     // API errors
     'api_no_authorization_found' => 'No s’ha trobat cap testimoni d’autorització en aquesta sol·licitud.',
diff --git a/lang/ca/notifications.php b/lang/ca/notifications.php
index fdea33436..49b986ad0 100644
--- a/lang/ca/notifications.php
+++ b/lang/ca/notifications.php
@@ -13,7 +13,7 @@ return [
     'updated_page_debounce' => 'Per a evitar que s’acumulin les notificacions, durant un temps no se us notificarà cap canvi fet en aquesta pàgina pel mateix usuari.',
 
     'detail_page_name' => 'Nom de la pàgina:',
-    'detail_page_path' => 'Camí de la pàgina:',
+    'detail_page_path' => 'Ruta de la pàgina:',
     'detail_commenter' => 'Autor del comentari:',
     'detail_comment' => 'Comentari:',
     'detail_created_by' => 'Creada per:',
diff --git a/lang/ca/settings.php b/lang/ca/settings.php
index 3cabbe1a3..63352147e 100644
--- a/lang/ca/settings.php
+++ b/lang/ca/settings.php
@@ -8,7 +8,7 @@ return [
 
     // Common Messages
     'settings' => 'Configuració',
-    'settings_save' => 'Configuració de desat',
+    'settings_save' => 'Guardar configuració',
     'system_version' => 'Versió de sistema',
     'categories' => 'Categories',
 
@@ -19,8 +19,8 @@ return [
     'app_name_desc' => 'El nom es mostra a la capçalera i als correus electrònics enviats pel sistema.',
     'app_name_header' => 'Mostra el nom a la capçalera',
     'app_public_access' => 'Accés públic',
-    'app_public_access_desc' => 'Si activeu aquesta opció les visitants podran accedir a la vostra instància del BookStack sense iniciar sessió.',
-    'app_public_access_desc_guest' => 'L’accés per als visitants públics es pot gestionar amb l’usuari &laquo;Convidat&raquo;.',
+    'app_public_access_desc' => 'Si activeu aquesta opció permetrà als visitants, accedir a la vostra instància del BookStack sense iniciar sessió.',
+    'app_public_access_desc_guest' => 'L’accés per als visitants públics es pot gestionar amb l’usuari Convidat.',
     'app_public_access_toggle' => 'Permet l’accés públic',
     'app_public_viewing' => 'Esteu segur que voleu permetre l’accés públic?',
     'app_secure_images' => 'Pujada d’imatges amb més seguretat',
@@ -75,34 +75,34 @@ return [
     'reg_confirm_restrict_domain_placeholder' => 'No hi ha cap restricció',
 
     // Sorting Settings
-    'sorting' => 'Sorting',
-    'sorting_book_default' => 'Default Book Sort',
-    'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.',
-    'sorting_rules' => 'Sort Rules',
-    'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.',
-    'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books',
-    'sort_rule_create' => 'Create Sort Rule',
-    'sort_rule_edit' => 'Edit Sort Rule',
-    'sort_rule_delete' => 'Delete Sort Rule',
-    'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.',
-    'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?',
-    'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?',
-    'sort_rule_details' => 'Sort Rule Details',
-    'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.',
-    'sort_rule_operations' => 'Sort Operations',
-    'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.',
-    'sort_rule_available_operations' => 'Available Operations',
-    'sort_rule_available_operations_empty' => 'No operations remaining',
-    'sort_rule_configured_operations' => 'Configured Operations',
-    'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list',
+    'sorting' => 'Ordenar',
+    'sorting_book_default' => 'Ordre predeterminat del llibre',
+    'sorting_book_default_desc' => 'Selecciona la regla d\'ordenació predeterminada per aplicar a nous llibres. Això no afectarà els llibres existents, i pot ser anul·lat per llibre.',
+    'sorting_rules' => 'Regles d\'ordenació',
+    'sorting_rules_desc' => 'Són operacions d\'ordenació predefinides que es poden aplicar al contingut en el sistema.',
+    'sort_rule_assigned_to_x_books' => 'Assignat a :count llibre | Assignat a :count llibres',
+    'sort_rule_create' => 'Crear regla d\'ordenació',
+    'sort_rule_edit' => 'Editar regla d\'ordenació',
+    'sort_rule_delete' => 'Eliminar regla d\'ordenació',
+    'sort_rule_delete_desc' => 'Eliminar aquesta regla d\'ordenació del sistema. Els llibres que utilitzin aquest tipus, es revertiran a l\'ordenació manual.',
+    'sort_rule_delete_warn_books' => 'Aquesta regla d\'ordenació s\'utilitza actualment en :count llibre(s). Està segur que vol eliminar-la?',
+    'sort_rule_delete_warn_default' => 'Aquesta regla d\'ordenació s\'utilitza actualment com a predeterminada per als llibres. Està segur que vol eliminar-la?',
+    'sort_rule_details' => 'Detalls de la regla d\'ordenació',
+    'sort_rule_details_desc' => 'Defineix un nom per aquesta regla d\'ordenació, que apareixerà a les llistes quan els usuaris estiguin seleccionant un ordre.',
+    'sort_rule_operations' => 'Operacions d\'ordenació',
+    'sort_rule_operations_desc' => 'Configura les accions d\'ordenació a realitzar movent-les de la llista d\'operacions disponibles. En utilitzar-se, les operacions s\'aplicaran en ordre, de dalt cap a baix. Qualsevol canvi realitzat aquí, s\'aplicarà a tots els llibres assignats en guardar.',
+    'sort_rule_available_operations' => 'Operacions disponibles',
+    'sort_rule_available_operations_empty' => 'No hi ha operacions pendents',
+    'sort_rule_configured_operations' => 'Operacions configurades',
+    'sort_rule_configured_operations_empty' => 'Arrossegar/afegir operacions des de la llista d\'Operacions Disponibles',
     'sort_rule_op_asc' => '(Asc)',
     'sort_rule_op_desc' => '(Desc)',
-    'sort_rule_op_name' => 'Name - Alphabetical',
-    'sort_rule_op_name_numeric' => 'Name - Numeric',
-    'sort_rule_op_created_date' => 'Created Date',
-    'sort_rule_op_updated_date' => 'Updated Date',
-    'sort_rule_op_chapters_first' => 'Chapters First',
-    'sort_rule_op_chapters_last' => 'Chapters Last',
+    'sort_rule_op_name' => 'Nom - Alfabètic',
+    'sort_rule_op_name_numeric' => 'Nom - Numèric',
+    'sort_rule_op_created_date' => 'Data de creació',
+    'sort_rule_op_updated_date' => 'Data d\'actualització',
+    'sort_rule_op_chapters_first' => 'Capítols a l\'inici',
+    'sort_rule_op_chapters_last' => 'Capítols al final',
 
     // Maintenance settings
     'maint' => 'Manteniment',
@@ -139,7 +139,7 @@ return [
     'recycle_bin_contents_empty' => 'La paperera és buida',
     'recycle_bin_empty' => 'Buida la paperera',
     'recycle_bin_empty_confirm' => 'Se suprimiran permanentment tots els elements que hi ha a la paperera incloent-hi el contingut que hi hagi a cada element. Esteu segur que voleu buidar la paperera?',
-    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
+    'recycle_bin_destroy_confirm' => 'Aquesta acció suprimirà del sistema de manera permanent aquest element, juntament amb tots els fills que es llisten a sota, i no podreu restaurar aquest contingut. Segur que voleu suprimir de manera permanent aquest element?',
     'recycle_bin_destroy_list' => 'Elements per destruir',
     'recycle_bin_restore_list' => 'Elements per restaurar',
     'recycle_bin_restore_confirm' => 'Aquesta acció restaurarà l’element suprimit, incloent-hi els elements fills, a la seva ubicació original. Si la ubicació original s’ha suprimit i és a la paperera, l’element pare també s’haurà de restaurar.',
@@ -192,7 +192,7 @@ return [
     'role_access_api' => 'Accés a l’API del sistema',
     'role_manage_settings' => 'Gestió de la configuració de l’aplicació',
     'role_export_content' => 'Exportació de contingut',
-    'role_import_content' => 'Import content',
+    'role_import_content' => 'Importar contingut',
     'role_editor_change' => 'Canvi de l’editor de pàgina',
     'role_notifications' => 'Recepció i gestió de notificacions',
     'role_asset' => 'Permisos de recursos',
@@ -308,13 +308,13 @@ return [
     'webhooks_last_error_message' => 'Darrer missatge d’error:',
 
     // Licensing
-    'licenses' => 'Licenses',
-    'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.',
-    'licenses_bookstack' => 'BookStack License',
-    'licenses_php' => 'PHP Library Licenses',
-    'licenses_js' => 'JavaScript Library Licenses',
-    'licenses_other' => 'Other Licenses',
-    'license_details' => 'License Details',
+    'licenses' => 'Llicències',
+    'licenses_desc' => 'Aquesta pàgina detalla informació sobre la llicència de BookStack a més dels projectes i biblioteques que s\'utilitzen en BookStack. Molts projectes enumerats aquí poden ser utilitzats només en un context de desenvolupament.',
+    'licenses_bookstack' => 'Llicència de BookStack',
+    'licenses_php' => 'Llicències de Biblioteques de PHP',
+    'licenses_js' => 'Llicències de Biblioteques de JavaScript',
+    'licenses_other' => 'Altres llicències',
+    'license_details' => 'Detalls de la llicència',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/ca/validation.php b/lang/ca/validation.php
index e33ae336f..bc821749a 100644
--- a/lang/ca/validation.php
+++ b/lang/ca/validation.php
@@ -105,10 +105,10 @@ return [
     'url'                  => 'El format :attribute no és vàlid.',
     'uploaded'             => 'No s’ha pogut pujar el fitxer. És possible que el servidor no admeti fitxers d’aquesta mida.',
 
-    'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
-    'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
-    'zip_model_expected' => 'Data object expected but ":type" found.',
-    'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
+    'zip_file' => 'El :attribute necessita fer referència a un arxiu dins del ZIP.',
+    'zip_file_mime' => 'El :attribute necessita fer referència a un arxiu de tipus :validTyes, trobat :foundType.',
+    'zip_model_expected' => 'S\'esperava un objecte de dades, però s\'ha trobat ":type".',
+    'zip_unique' => 'El :attribute ha de ser únic pel tipus d\'objecte dins del ZIP.',
 
     // Custom validation lines
     'custom' => [
diff --git a/lang/cs/common.php b/lang/cs/common.php
index af02491e4..25c32b039 100644
--- a/lang/cs/common.php
+++ b/lang/cs/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Vytvořit',
     'update' => 'Aktualizovat',
     'edit' => 'Upravit',
+    'archive' => 'Archivovat',
+    'unarchive' => 'Od-Archivovat',
     'sort' => 'Seřadit',
     'move' => 'Přesunout',
     'copy' => 'Kopírovat',
diff --git a/lang/cs/editor.php b/lang/cs/editor.php
index 15daa778c..2a6b344d5 100644
--- a/lang/cs/editor.php
+++ b/lang/cs/editor.php
@@ -47,7 +47,8 @@ return [
     'strikethrough' => 'Proškrtnuté',
     'superscript' => 'horní index',
     'subscript' => 'Dolní index',
-    'text_color' => 'Barva textu:',
+    'text_color' => 'Barva textu',
+    'highlight_color' => 'Barva zvýraznění',
     'custom_color' => 'Vlastní barva',
     'remove_color' => 'Odstranit barvu',
     'background_color' => 'Barva pozadí',
diff --git a/lang/cs/entities.php b/lang/cs/entities.php
index 6add3175f..cda6b6c82 100644
--- a/lang/cs/entities.php
+++ b/lang/cs/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Potvrzením odstraníte nahraný ZIP soubor. Tento krok nelze vrátit zpět.',
     'import_errors' => 'Chyby importu',
     'import_errors_desc' => 'Při pokusu o import došlo k následujícím chybám:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Oprávnění',
@@ -134,7 +138,7 @@ return [
     'shelves_copy_permissions_to_books' => 'Kopírovat oprávnění na knihy',
     'shelves_copy_permissions' => 'Kopírovat oprávnění',
     'shelves_copy_permissions_explain' => 'Tímto se použije aktuální nastavení oprávnění police na všechny knihy v ní obsažené. Před aktivací se ujistěte, že byly uloženy všechny změny oprávnění této police.',
-    'shelves_copy_permission_success' => 'Oprávnění police byla zkopírována na :count knih',
+    'shelves_copy_permission_success' => '{1}Oprávnění police byla zkopírována na :count knihu|[2,4]Oprávnění police byla zkopírována na :count knihy|[5,*]Oprávnění police byla zkopírována na :count knih',
 
     // Books
     'book' => 'Kniha',
@@ -194,7 +198,7 @@ return [
     // Chapters
     'chapter' => 'Kapitola',
     'chapters' => 'Kapitoly',
-    'x_chapters' => '{0}:count Kapitol|{1}:count Kapitola|[2,4]:count Kapitoly|[5,*]:count Kapitol',
+    'x_chapters' => '{0}:count kapitol|{1}:count kapitola|[2,4]:count kapitoly|[5,*]:count kapitol',
     'chapters_popular' => 'Populární kapitoly',
     'chapters_new' => 'Nová kapitola',
     'chapters_create' => 'Vytvořit novou kapitolu',
@@ -219,7 +223,7 @@ return [
     // Pages
     'page' => 'Stránka',
     'pages' => 'Stránky',
-    'x_pages' => '{0}:count Stran|{1}:count Strana|[2,4]:count Strany|[5,*]:count Stran',
+    'x_pages' => '{0}:count stran|{1}:count strana|[2,4]:count strany|[5,*]:count stran',
     'pages_popular' => 'Populární stránky',
     'pages_new' => 'Nová stránka',
     'pages_attachments' => 'Přílohy',
@@ -244,11 +248,11 @@ return [
     'pages_edit_delete_draft_confirm' => 'Jste si jisti, že chcete odstranit změny vašich konceptů? Všechny vaše změny, od posledního úplného uložení, budou ztraceny a editor bude aktualizován s nejnovějším stavem nekonceptu stránky.',
     'pages_edit_discard_draft' => 'Zahodit koncept',
     'pages_edit_switch_to_markdown' => 'Přepnout na Markdown Editor',
-    'pages_edit_switch_to_markdown_clean' => '(Vytvořený obsah)',
-    'pages_edit_switch_to_markdown_stable' => '(Stabilní obsah)',
+    'pages_edit_switch_to_markdown_clean' => '(Prostý zápis)',
+    'pages_edit_switch_to_markdown_stable' => '(Formátovaný zápis)',
     'pages_edit_switch_to_wysiwyg' => 'Přepnout na WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Přepnout na nový WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(V alfa testování)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(V beta testování)',
     'pages_edit_set_changelog' => 'Nastavit protokol změn',
     'pages_edit_enter_changelog_desc' => 'Zadejte stručný popis změn, které jste provedli',
     'pages_edit_enter_changelog' => 'Zadejte protokol změn',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Vložit kresbu',
     'pages_md_show_preview' => 'Zobrazit náhled',
     'pages_md_sync_scroll' => 'Synchronizovat náhled',
+    'pages_md_plain_editor' => 'Upravovat prostý text',
     'pages_drawing_unsaved' => 'Nalezen neuložený výkres',
     'pages_drawing_unsaved_confirm' => 'Byly nalezeny neuložené kresby z předchozí neúspěšné pokusu o uložení kresby. Chcete je obnovit a pokračovat v úpravě této neuložené kresby?',
     'pages_not_in_chapter' => 'Stránka není v kapitole',
@@ -312,7 +317,7 @@ return [
     'pages_draft_edited_notification' => 'Tato stránka se od té doby změnila. Je doporučeno aktuální koncept zahodit.',
     'pages_draft_page_changed_since_creation' => 'Tato stránka byla aktualizována od vytvoření tohoto konceptu. Doporučuje se zrušit tento koncept nebo se postarat o to, abyste si nepřepsali žádné již zadané změny.',
     'pages_draft_edit_active' => [
-        'start_a' => 'Uživatelé začali upravovat tuto stránku (celkem :count)',
+        'start_a' => '{1}:count uživatel začal upravovat tuto stránku|[2,4]:count uživatelé začali upravovat tuto stránku|[5,*]:count uživatelů začalo upravovat tuto stránku',
         'start_b' => ':userName začal/a upravovat tuto stránku',
         'time_a' => 'od doby, kdy byla tato stránky naposledy aktualizována',
         'time_b' => 'v posledních minutách (:minCount min.)',
@@ -342,7 +347,7 @@ return [
     'tags_assigned_chapters' => 'Přiřazeno ke kapitolám',
     'tags_assigned_books' => 'Přiřazeno ke knihám',
     'tags_assigned_shelves' => 'Přiřazeno k policím',
-    'tags_x_unique_values' => ':count jedinečných hodnot',
+    'tags_x_unique_values' => '{1}:count jedinečná hodnota|[2,4]:count jedinečné hodnoty|[5,*]:count jedinečných hodnot',
     'tags_all_values' => 'Všechny hodnoty',
     'tags_view_tags' => 'Zobrazit štítky',
     'tags_view_existing_tags' => 'Zobrazit existující štítky',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Komentář',
     'comments' => 'Komentáře',
     'comment_add' => 'Přidat komentář',
+    'comment_none' => 'Žádné komentáře k zobrazení',
     'comment_placeholder' => 'Zde zadejte komentář',
-    'comment_count' => '{0} Bez komentářů|{1} 1 komentář|[2,4] :count komentáře|[5,*] :count komentářů',
+    'comment_thread_count' => '{0}:count vláken komentářů|{1}:count vlákno komentářů|[2,4]:count vlákna komentářů|[5,*]:count vláken komentářů',
+    'comment_archived_count' => '[0,1]:count archivováno|[2,4]:count archivována|[5,*]:count archivováno',
+    'comment_archived_threads' => 'Archivovaná vlákna',
     'comment_save' => 'Uložit komentář',
     'comment_new' => 'Nový komentář',
     'comment_created' => 'komentováno :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Komentář odstraněn',
     'comment_created_success' => 'Komentář přidán',
     'comment_updated_success' => 'Komentář aktualizován',
+    'comment_archive_success' => 'Komentář archivován',
+    'comment_unarchive_success' => 'Komentář od-archivován',
+    'comment_view' => 'Zobrazit komentář',
+    'comment_jump_to_thread' => 'Přejít na vlákno',
     'comment_delete_confirm' => 'Opravdu chcete odstranit tento komentář?',
     'comment_in_reply_to' => 'Odpověď na :commentId',
+    'comment_reference' => 'Odkaz',
+    'comment_reference_outdated' => '(Zastaralý)',
     'comment_editor_explain' => 'Zde jsou komentáře, které zůstaly na této stránce. Komentáře lze přidat a spravovat při prohlížení uložené stránky.',
 
     // Revision
diff --git a/lang/cs/settings.php b/lang/cs/settings.php
index e4708f6b9..e72d4b403 100644
--- a/lang/cs/settings.php
+++ b/lang/cs/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/cy/common.php b/lang/cy/common.php
index c8c770b71..b42e0d44b 100644
--- a/lang/cy/common.php
+++ b/lang/cy/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Creu',
     'update' => 'Diweddaru',
     'edit' => 'Golygu',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Trefnu',
     'move' => 'Symud',
     'copy' => 'Copïo',
diff --git a/lang/cy/editor.php b/lang/cy/editor.php
index 93e1aed20..980099ffd 100644
--- a/lang/cy/editor.php
+++ b/lang/cy/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Uwchysgrif',
     'subscript' => 'Isysgrif',
     'text_color' => 'Lliw testun',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Lliw addasu',
     'remove_color' => 'Dileu lliw',
     'background_color' => 'Lliw cefnder',
diff --git a/lang/cy/entities.php b/lang/cy/entities.php
index bf4bc554c..e6df4317b 100644
--- a/lang/cy/entities.php
+++ b/lang/cy/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Bydd hwn yn dileu\'r mewnforyn ffeil ZIP sy wedi\'i lwytho i fyny, a fydd e ddim gallu cael ei ddadwneud.',
     'import_errors' => 'Gwallau Mewnforyn',
     'import_errors_desc' => 'Digwyddodd y gwallau canlynol yn ystod cynnig y mewnforyn:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Caniatâd',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Cynnwys Glân)',
     'pages_edit_switch_to_wysiwyg' => 'Newid i Olygydd WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Newid i WYSIWYG newydd',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(Mewn Profi Alpha)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Gosod Changelog',
     'pages_edit_enter_changelog_desc' => 'Rhowch ddisgrifiad byr o\'r newidiadau rydych wedi\'u gwneud',
     'pages_edit_enter_changelog' => 'Cofnodwch Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Mewnosod Llun',
     'pages_md_show_preview' => 'Dangos rhagolwg',
     'pages_md_sync_scroll' => 'Cydamseru sgrôl ragolwg',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Canfuwyd Llun heb ei Gadw',
     'pages_drawing_unsaved_confirm' => 'Canfuwyd data llun heb ei gadw o ymgais aflwyddiannus blaenorol i gadw llun. Hoffech chi adfer a pharhau i olygu\'r llun heb ei gadw?',
     'pages_not_in_chapter' => 'Nid yw\'r dudalen mewn pennod',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Sylw',
     'comments' => 'Sylwadau',
     'comment_add' => 'Ychwanegu Sylw',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Gadewch sylw yma',
-    'comment_count' => '{0} Dim sylwadau|{1} 1 Sylw| [2,*] :count Sylwadau',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Cadw Sylw',
     'comment_new' => 'Sylw Newydd',
     'comment_created' => 'sylwodd :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Dilëwyd sylw',
     'comment_created_success' => 'Ychwanegwyd sylw',
     'comment_updated_success' => 'Diweddarwyd sylw',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Ydych chi\'n siwr eich bod eisiau dileu\'r sylw hwn?',
     'comment_in_reply_to' => 'Mewn ymateb i :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Dyma\'r sylwadau sydd wedi eu gadael ar y dudalen hon. Gellir ychwanegu a rheoli sylwadau wrth edrych ar y dudalen a gadwyd.',
 
     // Revision
diff --git a/lang/cy/settings.php b/lang/cy/settings.php
index fb1457e1e..cb08e8c90 100644
--- a/lang/cy/settings.php
+++ b/lang/cy/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/da/auth.php b/lang/da/auth.php
index 9f717a3d5..dcc531125 100644
--- a/lang/da/auth.php
+++ b/lang/da/auth.php
@@ -6,11 +6,11 @@
  */
 return [
 
-    'failed' => 'Dee indtastede brugeroplysninger stemmer ikke overens med vores registreringer.',
+    'failed' => 'De indtastede brugeroplysninger stemmer ikke overens med vores registreringer.',
     'throttle' => 'For mange mislykkede loginforsøg. Prøv igen om :seconds sekunder.',
 
     // Login & Register
-    'sign_up' => 'Registrér',
+    'sign_up' => 'Registrer',
     'log_in' => 'Log ind',
     'log_in_with' => 'Log ind med :socialDriver',
     'sign_up_with' => 'Registrér med :socialDriver',
@@ -45,28 +45,28 @@ return [
 
     // Password Reset
     'reset_password' => 'Nulstil adgangskode',
-    'reset_password_send_instructions' => 'Indtast din E-Mail herunder og du vil blive sendt en E-Mail med et link til at nulstille din adgangskode.',
+    'reset_password_send_instructions' => 'Indtast din e-mail herunder og du vil blive sendt en e-mail med et link til at nulstille din adgangskode.',
     'reset_password_send_button' => 'Send link til nulstilling',
     'reset_password_sent' => 'Et link til nulstilling af adgangskode sendes til :email, hvis den e-mail-adresse findes i systemet.',
     'reset_password_success' => 'Din adgangskode er blevet nulstillet.',
     'email_reset_subject' => 'Nulstil din :appName adgangskode',
-    'email_reset_text' => 'Du modtager denne E-Mail fordi vi har modtaget en anmodning om at nulstille din adgangskode.',
+    'email_reset_text' => 'Du modtager denne e-mail fordi vi har modtaget en anmodning om at nulstille din adgangskode.',
     'email_reset_not_requested' => 'Hvis du ikke har anmodet om at få din adgangskode nulstillet, behøver du ikke at foretage dig noget.',
 
     // Email Confirmation
     'email_confirm_subject' => 'Bekræft din E-Mail på :appName',
     'email_confirm_greeting' => 'Tak for at oprette dig på :appName!',
-    'email_confirm_text' => 'Bekræft venligst din E-Mail adresse ved at klikke på linket nedenfor:',
-    'email_confirm_action' => 'Bekræft E-Mail',
-    'email_confirm_send_error' => 'E-Mail-bekræftelse kræves, men systemet kunne ikke sende E-Mailen. Kontakt administratoren for at sikre, at E-Mail er konfigureret korrekt.',
-    'email_confirm_success' => 'Din email er blevet bekræftet! Du bør nu kune logge ind med denne emailadresse.',
+    'email_confirm_text' => 'Bekræft venligst din e-mail adresse ved at klikke på linket nedenfor:',
+    'email_confirm_action' => 'Bekræft e-mail',
+    'email_confirm_send_error' => 'E-mailbekræftelse kræves, men systemet kunne ikke sende e-mailen. Kontakt administratoren for at sikre, at e-mail er konfigureret korrekt.',
+    'email_confirm_success' => 'Din e-mail er blevet bekræftet! Du bør nu kunne logge ind med denne e-mailadresse.',
     'email_confirm_resent' => 'Bekræftelsesmail sendt, tjek venligst din indboks.',
     'email_confirm_thanks' => 'Tak for bekræftelsen!',
     'email_confirm_thanks_desc' => 'Vent venligst et øjeblik, mens din bekræftelse behandles. Hvis du ikke bliver omdirigeret efter 3 sekunder, skal du trykke på linket "Fortsæt" nedenfor for at fortsætte.',
 
-    'email_not_confirmed' => 'E-Mail adresse ikke bekræftet',
-    'email_not_confirmed_text' => 'Din E-Mail adresse er endnu ikke blevet bekræftet.',
-    'email_not_confirmed_click_link' => 'Klik venligst på linket i E-Mailen der blev sendt kort efter du registrerede dig.',
+    'email_not_confirmed' => 'E-mailadresse ikke bekræftet',
+    'email_not_confirmed_text' => 'Din e-mailadresse er endnu ikke blevet bekræftet.',
+    'email_not_confirmed_click_link' => 'Klik venligst på linket i e-mailen der blev sendt kort efter du registrerede dig.',
     'email_not_confirmed_resend' => 'Hvis du ikke kan finde E-Mailen, kan du du få gensendt bekræftelsesemailen ved at trykke herunder.',
     'email_not_confirmed_resend_button' => 'Gensend bekræftelsesemail',
 
diff --git a/lang/da/common.php b/lang/da/common.php
index 75060b28b..db75772c8 100644
--- a/lang/da/common.php
+++ b/lang/da/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Opret',
     'update' => 'Opdater',
     'edit' => 'Rediger',
+    'archive' => 'Arkiver',
+    'unarchive' => 'Tilbagekald',
     'sort' => 'Sorter',
     'move' => 'Flyt',
     'copy' => 'Kopier',
@@ -48,8 +50,8 @@ return [
     'unfavourite' => 'Fjern som foretrukken',
     'next' => 'Næste',
     'previous' => 'Forrige',
-    'filter_active' => 'Aktivt Filter:',
-    'filter_clear' => 'Nulstil Filter',
+    'filter_active' => 'Aktivt filter:',
+    'filter_clear' => 'Nulstil filter',
     'download' => 'Hent',
     'open_in_tab' => 'Åben i ny fane',
     'open' => 'Åbn',
diff --git a/lang/da/components.php b/lang/da/components.php
index 410ed4fc5..3deeadf48 100644
--- a/lang/da/components.php
+++ b/lang/da/components.php
@@ -6,8 +6,8 @@ return [
 
     // Image Manager
     'image_select' => 'Billedselektion',
-    'image_list' => 'Billede Liste',
-    'image_details' => 'Billede Detaljer',
+    'image_list' => 'Billedliste',
+    'image_details' => 'Billeddetaljer',
     'image_upload' => 'Upload billede',
     'image_intro' => 'Her kan du vælge og administrere billeder, der tidligere er blevet uploadet til systemet.',
     'image_intro_upload' => 'Upload et nyt billede ved at trække en billedfil ind i dette vindue, eller ved at bruge knappen "Upload billede" ovenfor.',
diff --git a/lang/da/editor.php b/lang/da/editor.php
index 2f9b9f936..4c8aa0f72 100644
--- a/lang/da/editor.php
+++ b/lang/da/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Hævet',
     'subscript' => 'Sænket',
     'text_color' => 'Tekstfarve',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Tilpasset farve',
     'remove_color' => 'Fjern farve',
     'background_color' => 'Baggrundsfarve',
diff --git a/lang/da/entities.php b/lang/da/entities.php
index cfd2a1936..66e3fd206 100644
--- a/lang/da/entities.php
+++ b/lang/da/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Dette vil slette den uploadede ZIP-fil og kan ikke fortrydes.',
     'import_errors' => 'Importfejl',
     'import_errors_desc' => 'Følgende fejl opstod under importforsøget:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Rettigheder',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stabilt indhold)',
     'pages_edit_switch_to_wysiwyg' => 'Skift til WYSIWYG redigering',
     'pages_edit_switch_to_new_wysiwyg' => 'Skift til ny WYSIWYG (Hvad man ser, er hvad man får)',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(I alfa-testning)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Sæt ændringsoversigt',
     'pages_edit_enter_changelog_desc' => 'Indtast en kort beskrivelse af ændringer du har lavet',
     'pages_edit_enter_changelog' => 'Indtast ændringsoversigt',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Indsæt tegning',
     'pages_md_show_preview' => 'Vis forhåndsvisning',
     'pages_md_sync_scroll' => 'Rulning af forhåndsvisning af synkronisering',
+    'pages_md_plain_editor' => 'Klartekst editor',
     'pages_drawing_unsaved' => 'Ikke gemt tegning fundet',
     'pages_drawing_unsaved_confirm' => 'Der blev fundet ikke-gemte tegningsdata fra et tidligere mislykket forsøg på at gemme en tegning. Vil du gendanne og fortsætte med at redigere denne ikke-gemte tegning?',
     'pages_not_in_chapter' => 'Side er ikke i et kapitel',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Kommentar',
     'comments' => 'Kommentarer',
     'comment_add' => 'Tilføj kommentar',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Skriv en kommentar her',
-    'comment_count' => '{0} Ingen kommentarer|{1} 1 Kommentar|[2,*] :count kommentarer',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Arkiveret',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Gem kommentar',
     'comment_new' => 'Ny kommentar',
     'comment_created' => 'kommenteret :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Kommentar slettet',
     'comment_created_success' => 'Kommentaren er tilføjet',
     'comment_updated_success' => 'Kommentaren er opdateret',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'Se kommentar',
+    'comment_jump_to_thread' => 'Hop til tråd',
     'comment_delete_confirm' => 'Er du sikker på, at du vil slette denne kommentar?',
     'comment_in_reply_to' => 'Som svar til :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Forældet)',
     'comment_editor_explain' => 'Her er de kommentarer, der er blevet efterladt på denne side. Kommentarer kan tilføjes og administreres, når du ser den gemte side.',
 
     // Revision
diff --git a/lang/da/passwords.php b/lang/da/passwords.php
index 343fa2b85..b58a4c94b 100644
--- a/lang/da/passwords.php
+++ b/lang/da/passwords.php
@@ -7,7 +7,7 @@
 return [
 
     'password' => 'Adgangskoder skal være mindst otte tegn og svare til bekræftelsen.',
-    'user' => "Vi kan ikke finde en bruger med den e-mail adresse.",
+    'user' => "Vi kan ikke finde en bruger med den e-mailadresse.",
     'token' => 'Linket til nulstilling af adgangskode er ugyldigt for denne e-mail-adresse.',
     'sent' => 'Vi har sendt dig en e-mail med et link til at nulstille adgangskoden!',
     'reset' => 'Dit kodeord er blevet nulstillet!',
diff --git a/lang/da/preferences.php b/lang/da/preferences.php
index 9b9436dba..143405a16 100644
--- a/lang/da/preferences.php
+++ b/lang/da/preferences.php
@@ -24,12 +24,12 @@ return [
     'notifications_opt_own_page_changes' => 'Adviser ved ændringer af sider, jeg ejer',
     'notifications_opt_own_page_comments' => 'Adviser ved kommentarer på sider, jeg ejer',
     'notifications_opt_comment_replies' => 'Adviser ved svar på mine kommentarer',
-    'notifications_save' => 'Gem Indstillinger',
+    'notifications_save' => 'Gem indstillinger',
     'notifications_update_success' => 'Indstillinger for notifikationer er blevet opdateret!',
-    'notifications_watched' => 'Overvågede & Ignorerede',
+    'notifications_watched' => 'Overvågede og ignorerede',
     'notifications_watched_desc' => 'Nedenfor er de elementer, der har brugerdefinerede overvågning aktivt. For at opdatere dine præferencer for disse, gå til elementet og find derefter overvågning i sidepanelet.',
 
-    'auth' => 'Adgang & Sikkerhed',
+    'auth' => 'Adgang og sikkerhed',
     'auth_change_password' => 'Skift adgangskode',
     'auth_change_password_desc' => 'Skift den adgangskode, du bruger til at logge ind med. Den skal være mindst 8 tegn lang.',
     'auth_change_password_success' => 'Adgangskoden er blevet opdateret!',
@@ -41,7 +41,7 @@ return [
     'profile_email_desc' => 'Denne e-mail vil blive brugt til notifikationer og, afhængigt af aktiv systemgodkendelse, systemadgang.',
     'profile_email_no_permission' => 'Desværre har du ikke tilladelse til at ændre din e-mailadresse. Hvis du ønsker at ændre dette, skal du bede en administrator om at ændre dette for dig.',
     'profile_avatar_desc' => 'Vælg et billede som vil blive brugt til at repræsentere dig selv over for andre i systemet. Ideelt set bør dette billede være kvadrat og omkring 256px i bredde og højde.',
-    'profile_admin_options' => 'Administrator Indstillinger',
+    'profile_admin_options' => 'Administratorindstillinger',
     'profile_admin_options_desc' => 'Yderligere indstillinger på administratorniveau, såsom dem der håndterer rolleopgaver, kan findes for din brugerkonto i området "Indstillinger > Brugere".',
 
     'delete_account' => 'Slet konto',
diff --git a/lang/da/settings.php b/lang/da/settings.php
index d964ed617..4f194545b 100644
--- a/lang/da/settings.php
+++ b/lang/da/settings.php
@@ -26,14 +26,14 @@ return [
     'app_secure_images' => 'Højere sikkerhed for billeduploads',
     'app_secure_images_toggle' => 'Aktiver højere sikkerhed for billeduploads',
     'app_secure_images_desc' => 'Af performanceårsager er alle billeder offentlige. Denne funktion tilføjer en tilfældig, vanskelig at gætte streng foran billed-url\'er. Sørg for, at mappeindeksering ikke er aktiveret for at forhindre nem adgang.',
-    'app_default_editor' => 'Standard Side Editor',
+    'app_default_editor' => 'Standard sideeditor',
     'app_default_editor_desc' => 'Vælg hvilken editor der som standard skal bruges ved redigering af nye sider. Dette kan tilsidesættes på side niveau, hvor tilladelser tillader det.',
     'app_custom_html' => 'Tilpasset HTML head indhold',
     'app_custom_html_desc' => 'Alt indhold tilføjet her, vil blive indsat i bunden af <head> sektionen på alle sider. Dette er brugbart til overskrivning af styles og tilføjelse af analytics kode.',
     'app_custom_html_disabled_notice' => 'Brugerdefineret HTML head indhold er deaktiveret på denne indstillingsside for at, at ændringer kan rulles tilbage.',
     'app_logo' => 'Applikationslogo',
     'app_logo_desc' => 'Det bruges blandt andet i applikationens headerbar. Dette billede skal være 86px i højden. Store billeder vil blive skaleret ned.',
-    'app_icon' => 'Program ikon',
+    'app_icon' => 'Programikon',
     'app_icon_desc' => 'Dette ikon bruges til browserfaner og genvejsikoner. Det skal være et 256px kvadratisk PNG-billede.',
     'app_homepage' => 'Applikationsforside',
     'app_homepage_desc' => 'Vælg en visning, der skal vises på forsiden i stedet for standardvisningen. Sidetilladelser ignoreres for de valgte sider.',
@@ -245,7 +245,7 @@ return [
     'users_social_accounts_info' => 'Her kan du forbinde dine andre konti for hurtigere og lettere login. Afbrydelse af en konto her tilbagekalder ikke tidligere autoriseret adgang. Tilbagekald adgang fra dine profilindstillinger på den tilsluttede sociale konto.',
     'users_social_connect' => 'Forbind konto',
     'users_social_disconnect' => 'Frakobl konto',
-    'users_social_status_connected' => 'Tilsuttet',
+    'users_social_status_connected' => 'Tilsluttet',
     'users_social_status_disconnected' => 'Afbrudt',
     'users_social_connected' => ':socialAccount kontoen blev knyttet til din profil.',
     'users_social_disconnected' => ':socialAccount kontoen blev afbrudt fra din profil.',
@@ -289,7 +289,7 @@ return [
     'webhooks_details' => 'Webhook detaljer',
     'webhooks_details_desc' => 'Angiv et brugervenligt navn og et POST endpoint som en lokation for webhook data at blive sendt til.',
     'webhooks_events' => 'Webhook Begivenheder',
-    'webhooks_events_desc' => 'Vælg alle begivenhederd er skal udløse denne webhook til at blive kaldt.',
+    'webhooks_events_desc' => 'Vælg alle begivenheder der skal udløse denne webhook til at blive kaldt.',
     'webhooks_events_warning' => 'Husk, at disse begivenheder vil blive udløst for alle valgte begivenheder, selv om brugerdefinerede tilladelser bliver anvendt. Sørg for, at brugen af denne webhook ikke vil afsløre fortroligt indhold.',
     'webhooks_events_all' => 'Alle systemhændelser',
     'webhooks_name' => 'Webhook Navn',
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/de/common.php b/lang/de/common.php
index 8ff91da0a..4f2ae54d2 100644
--- a/lang/de/common.php
+++ b/lang/de/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Erstellen',
     'update' => 'Aktualisieren',
     'edit' => 'Bearbeiten',
+    'archive' => 'Archivieren',
+    'unarchive' => 'Nicht mehr archivieren',
     'sort' => 'Sortieren',
     'move' => 'Verschieben',
     'copy' => 'Kopieren',
diff --git a/lang/de/editor.php b/lang/de/editor.php
index 41ec74e52..1be315519 100644
--- a/lang/de/editor.php
+++ b/lang/de/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Hochgestellt',
     'subscript' => 'Tiefgestellt',
     'text_color' => 'Schriftfarbe',
+    'highlight_color' => 'Markierungsfarbe',
     'custom_color' => 'Benutzerdefinierte Farbe',
     'remove_color' => 'Farbe entfernen',
     'background_color' => 'Hintergrundfarbe',
diff --git a/lang/de/entities.php b/lang/de/entities.php
index 767696eb3..94c327b7e 100644
--- a/lang/de/entities.php
+++ b/lang/de/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Dies löscht die hochgeladene ZIP-Datei und kann nicht rückgängig gemacht werden.',
     'import_errors' => 'Importfehler',
     'import_errors_desc' => 'Die folgenden Fehler sind während des Importversuchs aufgetreten:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigiere in Büchern',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Berechtigungen',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stabiler Inhalt)',
     'pages_edit_switch_to_wysiwyg' => 'Zum WYSIWYG-Editor wechseln',
     'pages_edit_switch_to_new_wysiwyg' => 'Zum neuen WYSIWYG wechseln',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha-Testphase)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(Im Beta-Test)',
     'pages_edit_set_changelog' => 'Änderungsprotokoll hinzufügen',
     'pages_edit_enter_changelog_desc' => 'Bitte geben Sie eine kurze Zusammenfassung Ihrer Änderungen ein',
     'pages_edit_enter_changelog' => 'Änderungsprotokoll eingeben',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Zeichnung einfügen',
     'pages_md_show_preview' => 'Vorschau anzeigen',
     'pages_md_sync_scroll' => 'Vorschau synchronisieren',
+    'pages_md_plain_editor' => 'Einfacher Editor',
     'pages_drawing_unsaved' => 'Ungespeicherte Zeichnung gefunden',
     'pages_drawing_unsaved_confirm' => 'Es wurden ungespeicherte Zeichnungsdaten von einem früheren, fehlgeschlagenen Versuch, die Zeichnung zu speichern, gefunden. Möchten Sie diese ungespeicherte Zeichnung wiederherstellen und weiter bearbeiten?',
     'pages_not_in_chapter' => 'Seite ist in keinem Kapitel',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Kommentar',
     'comments' => 'Kommentare',
     'comment_add' => 'Kommentieren',
+    'comment_none' => 'Keine Kommentare vorhanden',
     'comment_placeholder' => 'Geben Sie hier Ihre Kommentare ein',
-    'comment_count' => '{0} Keine Kommentare|{1} 1 Kommentar|[2,*] :count Kommentare',
+    'comment_thread_count' => ':count Thema|:count Themen',
+    'comment_archived_count' => ':count archiviert',
+    'comment_archived_threads' => 'Archivierte Themen',
     'comment_save' => 'Kommentar speichern',
     'comment_new' => 'Neuer Kommentar',
     'comment_created' => ':createDiff kommentiert',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Kommentar gelöscht',
     'comment_created_success' => 'Kommentar hinzugefügt',
     'comment_updated_success' => 'Kommentar aktualisiert',
+    'comment_archive_success' => 'Kommentar archiviert',
+    'comment_unarchive_success' => 'Kommentar nicht mehr archiviert',
+    'comment_view' => 'Kommentar ansehen',
+    'comment_jump_to_thread' => 'Zum Thema springen',
     'comment_delete_confirm' => 'Möchten Sie diesen Kommentar wirklich löschen?',
     'comment_in_reply_to' => 'Antwort auf :commentId',
+    'comment_reference' => 'Referenz',
+    'comment_reference_outdated' => '(Veraltet)',
     'comment_editor_explain' => 'Hier sind die Kommentare, die auf dieser Seite hinterlassen wurden. Kommentare können hinzugefügt und verwaltet werden, wenn die gespeicherte Seite angezeigt wird.',
 
     // Revision
diff --git a/lang/de/settings.php b/lang/de/settings.php
index a6b6482e3..250668e8a 100644
--- a/lang/de/settings.php
+++ b/lang/de/settings.php
@@ -349,6 +349,7 @@ Hinweis: Benutzer können ihre E-Mail-Adresse nach erfolgreicher Registrierung 
         'lt' => 'Litauisch',
         'lv' => 'Lettisch',
         'nb' => 'Norwegisch (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Niederländisch',
         'pl' => 'Polnisch',
diff --git a/lang/de_informal/common.php b/lang/de_informal/common.php
index bc33b8535..d07fa056f 100644
--- a/lang/de_informal/common.php
+++ b/lang/de_informal/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Anlegen',
     'update' => 'Aktualisieren',
     'edit' => 'Bearbeiten',
+    'archive' => 'Archivieren',
+    'unarchive' => 'Nicht mehr archivieren',
     'sort' => 'Sortieren',
     'move' => 'Verschieben',
     'copy' => 'Kopieren',
diff --git a/lang/de_informal/editor.php b/lang/de_informal/editor.php
index 3d6e24a23..30c38271c 100644
--- a/lang/de_informal/editor.php
+++ b/lang/de_informal/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Hochgestellt',
     'subscript' => 'Tiefgestellt',
     'text_color' => 'Textfarbe',
+    'highlight_color' => 'Markierungsfarbe',
     'custom_color' => 'Benutzerdefinierte Farbe',
     'remove_color' => 'Farbe entfernen',
     'background_color' => 'Hintergrundfarbe',
diff --git a/lang/de_informal/entities.php b/lang/de_informal/entities.php
index 9891bcb67..a3e6e5f36 100644
--- a/lang/de_informal/entities.php
+++ b/lang/de_informal/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Dies löscht die hochgeladene ZIP-Datei und kann nicht rückgängig gemacht werden.',
     'import_errors' => 'Importfehler',
     'import_errors_desc' => 'Die folgenden Fehler sind während des Importversuchs aufgetreten:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigiere in Büchern',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Berechtigungen',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stabiler Inhalt)',
     'pages_edit_switch_to_wysiwyg' => 'Zum WYSIWYG-Editor wechseln',
     'pages_edit_switch_to_new_wysiwyg' => 'Zum neuen WYSIWYG wechseln',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha-Testphase)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(Im Beta-Test)',
     'pages_edit_set_changelog' => 'Änderungsprotokoll hinzufügen',
     'pages_edit_enter_changelog_desc' => 'Bitte gib eine kurze Zusammenfassung deiner Änderungen ein',
     'pages_edit_enter_changelog' => 'Änderungsprotokoll eingeben',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Zeichnung einfügen',
     'pages_md_show_preview' => 'Vorschau anzeigen',
     'pages_md_sync_scroll' => 'Vorschau synchronisieren',
+    'pages_md_plain_editor' => 'Einfacher Editor',
     'pages_drawing_unsaved' => 'Ungespeicherte Zeichnung gefunden',
     'pages_drawing_unsaved_confirm' => 'Es wurden ungespeicherte Zeichnungsdaten von einem früheren, fehlgeschlagenen Versuch, die Zeichnung zu speichern, gefunden. Möchtest du diese ungespeicherte Zeichnung wiederherstellen und weiter bearbeiten?',
     'pages_not_in_chapter' => 'Seite ist in keinem Kapitel',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Kommentar',
     'comments' => 'Kommentare',
     'comment_add' => 'Kommentieren',
+    'comment_none' => 'Keine Kommentare vorhanden',
     'comment_placeholder' => 'Gib hier deine Kommentare ein',
-    'comment_count' => '{0} Keine Kommentare|{1} 1 Kommentar|[2,*] :count Kommentare',
+    'comment_thread_count' => ':count Thema|:count Themen',
+    'comment_archived_count' => ':count archiviert',
+    'comment_archived_threads' => 'Archivierte Themen',
     'comment_save' => 'Kommentar speichern',
     'comment_new' => 'Neuer Kommentar',
     'comment_created' => ':createDiff kommentiert',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Kommentar gelöscht',
     'comment_created_success' => 'Kommentar hinzugefügt',
     'comment_updated_success' => 'Kommentar aktualisiert',
+    'comment_archive_success' => 'Kommentar archiviert',
+    'comment_unarchive_success' => 'Kommentar nicht mehr archiviert',
+    'comment_view' => 'Kommentar ansehen',
+    'comment_jump_to_thread' => 'Zum Thema springen',
     'comment_delete_confirm' => 'Möchtst du diesen Kommentar wirklich löschen?',
     'comment_in_reply_to' => 'Antwort auf :commentId',
+    'comment_reference' => 'Referenz',
+    'comment_reference_outdated' => '(Veraltet)',
     'comment_editor_explain' => 'Hier sind die Kommentare, die auf dieser Seite hinterlassen wurden. Kommentare können hinzugefügt und verwaltet werden, wenn die gespeicherte Seite angezeigt wird.',
 
     // Revision
diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php
index 0b7b74c0d..50003c58e 100644
--- a/lang/de_informal/settings.php
+++ b/lang/de_informal/settings.php
@@ -349,6 +349,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
         'lt' => 'Litauisch',
         'lv' => 'Lettisch',
         'nb' => 'Norwegisch (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Niederländisch',
         'pl' => 'Polnisch',
diff --git a/lang/el/common.php b/lang/el/common.php
index 112133883..39c5aad18 100644
--- a/lang/el/common.php
+++ b/lang/el/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Δημιουργία',
     'update' => 'Ενημέρωση',
     'edit' => 'Επεξεργασία',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Ταξινόμηση',
     'move' => 'Μετακίνηση',
     'copy' => 'Αντιγραφή',
diff --git a/lang/el/editor.php b/lang/el/editor.php
index 701b384bd..9952e7fdf 100644
--- a/lang/el/editor.php
+++ b/lang/el/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Εκθέτης',
     'subscript' => 'Δείκτης',
     'text_color' => 'Χρώμα κειμένου',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Προσαρμογή χρώματος',
     'remove_color' => 'Αφαίρεση χρώματος',
     'background_color' => 'Χρώμα φόντου',
diff --git a/lang/el/entities.php b/lang/el/entities.php
index 8c043be4b..3779cd18a 100644
--- a/lang/el/entities.php
+++ b/lang/el/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Δικαιώματα',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Σταθερό Περιεχόμενο)',
     'pages_edit_switch_to_wysiwyg' => 'Εναλλαγή στον επεξεργαστή WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Ορισμός καταγραφής αλλαγών',
     'pages_edit_enter_changelog_desc' => 'Εισάγετε μια σύντομη περιγραφή των αλλαγών που κάνατε',
     'pages_edit_enter_changelog' => 'Εισαγωγή Αρχείου Καταγραφής Αλλαγών',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Εισαγωγή Σχεδίου',
     'pages_md_show_preview' => 'Εμφάνιση προεπισκόπησης',
     'pages_md_sync_scroll' => 'Συγχρονισμός προεπισκόπησης',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Η σελίδα δεν είναι σε κεφάλαιο',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Σχόλιο',
     'comments' => 'Σχόλια',
     'comment_add' => 'Προσθήκη Σχολίου',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Αφήστε ένα σχόλιο εδώ',
-    'comment_count' => '{0} Κανένα Σχόλιο |{1} 1 Σχόλιο |[2,*] :count Σχόλια',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Αποθήκευση Σχολίου',
     'comment_new' => 'Νέο Σχόλιο',
     'comment_created' => 'σχολίασε :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Σχόλιο διαγράφηκε',
     'comment_created_success' => 'Το σχόλιο προστέθηκε',
     'comment_updated_success' => 'Το σχόλιο ενημερώθηκε',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το σχόλιο;',
     'comment_in_reply_to' => 'Σε απάντηση στο :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/el/settings.php b/lang/el/settings.php
index 386921b8a..172038fd0 100644
--- a/lang/el/settings.php
+++ b/lang/el/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/en/common.php b/lang/en/common.php
index b05169bb2..06a9e855c 100644
--- a/lang/en/common.php
+++ b/lang/en/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Create',
     'update' => 'Update',
     'edit' => 'Edit',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sort',
     'move' => 'Move',
     'copy' => 'Copy',
diff --git a/lang/en/editor.php b/lang/en/editor.php
index 752c6f3f7..0d250e9a7 100644
--- a/lang/en/editor.php
+++ b/lang/en/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superscript',
     'subscript' => 'Subscript',
     'text_color' => 'Text color',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Custom color',
     'remove_color' => 'Remove color',
     'background_color' => 'Background color',
diff --git a/lang/en/entities.php b/lang/en/entities.php
index a74785eaa..74c50be3b 100644
--- a/lang/en/entities.php
+++ b/lang/en/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Set Changelog',
     'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
     'pages_edit_enter_changelog' => 'Enter Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insert Drawing',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Page is not in a chapter',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/en/settings.php b/lang/en/settings.php
index 82a4ade5d..4fcd2e2b4 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -76,6 +76,10 @@ return [
 
     // Sorting Settings
     'sorting' => 'Sorting',
+    'sorting_shelves_per_page' => 'Shelves Per Page',
+    'sorting_shelves_per_page_desc' => 'Sets the number of shelves shown per page in shelf listings.',
+    'sorting_books_per_page' => 'Books Per Page',
+    'sorting_books_per_page_desc' => 'Sets the number of books shown per page in book listings.',
     'sorting_book_default' => 'Default Book Sort',
     'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.',
     'sorting_rules' => 'Sort Rules',
@@ -348,6 +352,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/es/common.php b/lang/es/common.php
index 14eee82d9..399d1d862 100644
--- a/lang/es/common.php
+++ b/lang/es/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Crear',
     'update' => 'Actualizar',
     'edit' => 'Editar',
+    'archive' => 'Archivar',
+    'unarchive' => 'Desarchivar',
     'sort' => 'Ordenar',
     'move' => 'Mover',
     'copy' => 'Copiar',
diff --git a/lang/es/editor.php b/lang/es/editor.php
index 15f1dd9b7..2df05dede 100644
--- a/lang/es/editor.php
+++ b/lang/es/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superíndice',
     'subscript' => 'Subíndice',
     'text_color' => 'Color de texto',
+    'highlight_color' => 'Color de resaltado',
     'custom_color' => 'Color personalizado',
     'remove_color' => 'Eliminar color',
     'background_color' => 'Color de fondo',
diff --git a/lang/es/entities.php b/lang/es/entities.php
index 591babb69..e652d4dc0 100644
--- a/lang/es/entities.php
+++ b/lang/es/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Esto eliminará el archivo ZIP de importación subido y no se puede deshacer.',
     'import_errors' => 'Errores de Importación',
     'import_errors_desc' => 'Se han producido los siguientes errores durante el intento de importación:',
+    'breadcrumb_siblings_for_page' => 'Navegar por páginas del mismo nivel',
+    'breadcrumb_siblings_for_chapter' => 'Navegar por capítulos del mismo nivel',
+    'breadcrumb_siblings_for_book' => 'Navegar por libros del mismo nivel',
+    'breadcrumb_siblings_for_bookshelf' => 'Navegar por estantes del mismo nivel',
 
     // Permissions and restrictions
     'permissions' => 'Permisos',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Contenido Estable)',
     'pages_edit_switch_to_wysiwyg' => 'Cambiar a Editor WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Cambiar a nuevo editor WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(En alfa)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(En prueba beta)',
     'pages_edit_set_changelog' => 'Ajustar Log de cambios',
     'pages_edit_enter_changelog_desc' => 'Introduzca una breve descripción de los cambios que ha realizado',
     'pages_edit_enter_changelog' => 'Entrar al Log de cambios',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insertar Dibujo',
     'pages_md_show_preview' => 'Mostrar vista previa',
     'pages_md_sync_scroll' => 'Sincronizar desplazamiento de vista previa',
+    'pages_md_plain_editor' => 'Editor de texto plano',
     'pages_drawing_unsaved' => 'Encontrado dibujo sin guardar',
     'pages_drawing_unsaved_confirm' => 'Se encontraron datos no guardados del dibujo de un intento de guardado fallido. ¿Desea restaurar y continuar editando el dibujo no guardado?',
     'pages_not_in_chapter' => 'La página no está en un capítulo',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comentario',
     'comments' => 'Comentarios',
     'comment_add' => 'Añadir Comentario',
+    'comment_none' => 'No hay comentarios para mostrar',
     'comment_placeholder' => 'Introduzca su comentario aquí',
-    'comment_count' => '{0} Sin Comentarios|{1} 1 Comentario|[2,*] :count Comentarios',
+    'comment_thread_count' => ':count hilo de comentarios|:count hilos de comentarios',
+    'comment_archived_count' => ':count Archivados',
+    'comment_archived_threads' => 'Hilos archivados',
     'comment_save' => 'Guardar comentario',
     'comment_new' => 'Nuevo Comentario',
     'comment_created' => 'comentado :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comentario borrado',
     'comment_created_success' => 'Comentario añadido',
     'comment_updated_success' => 'Comentario actualizado',
+    'comment_archive_success' => 'Comentario archivado',
+    'comment_unarchive_success' => 'Comentario desarchivado',
+    'comment_view' => 'Ver comentario',
+    'comment_jump_to_thread' => 'Ir al hilo',
     'comment_delete_confirm' => '¿Está seguro de que quiere borrar este comentario?',
     'comment_in_reply_to' => 'En respuesta a :commentId',
+    'comment_reference' => 'Referencia',
+    'comment_reference_outdated' => '(obsoleto)',
     'comment_editor_explain' => 'Estos son los comentarios que se han escrito en esta página. Los comentarios se pueden añadir y administrar cuando se ve la página guardada.',
 
     // Revision
diff --git a/lang/es/settings.php b/lang/es/settings.php
index 0a4a9303d..55eb498d6 100644
--- a/lang/es/settings.php
+++ b/lang/es/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lituano',
         'lv' => 'Letón',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Holanda',
         'pl' => 'Polaco',
diff --git a/lang/es_AR/activities.php b/lang/es_AR/activities.php
index d7b93d730..e01fe4e64 100644
--- a/lang/es_AR/activities.php
+++ b/lang/es_AR/activities.php
@@ -10,7 +10,7 @@ return [
     'page_create_notification'    => 'Página creada correctamente',
     'page_update'                 => 'página actualizada',
     'page_update_notification'    => 'Página actualizada correctamente',
-    'page_delete'                 => 'página borrada',
+    'page_delete'                 => 'borró la página',
     'page_delete_notification'    => 'Página eliminada correctamente',
     'page_restore'                => 'página restaurada',
     'page_restore_notification'   => 'Página restaurada correctamente',
@@ -96,7 +96,7 @@ return [
     'user_create' => 'usuario creado',
     'user_create_notification' => 'Usuario creado correctamente',
     'user_update' => 'usuario actualizado',
-    'user_update_notification' => 'Usuario actualizado correctamente',
+    'user_update_notification' => 'Usuario actualizado con éxito',
     'user_delete' => 'usuario eliminado',
     'user_delete_notification' => 'El usuario fue eliminado correctamente',
 
@@ -129,11 +129,11 @@ return [
 
     // Sort Rules
     'sort_rule_create' => 'regla de ordenación creada',
-    'sort_rule_create_notification' => 'Rol de ordenación creada con éxito',
+    'sort_rule_create_notification' => 'Regla de ordenación creada correctamente',
     'sort_rule_update' => 'regla de ordenación actualizada',
     'sort_rule_update_notification' => 'Regla de ordenación actualizada correctamente',
     'sort_rule_delete' => 'regla de ordenación eliminada',
-    'sort_rule_delete_notification' => 'Rol de ordenación borrada con éxito',
+    'sort_rule_delete_notification' => 'Regla de ordenación eliminada correctamente',
 
     // Other
     'permissions_update'          => 'permisos actualizados',
diff --git a/lang/es_AR/auth.php b/lang/es_AR/auth.php
index 1316dd686..7687f709e 100644
--- a/lang/es_AR/auth.php
+++ b/lang/es_AR/auth.php
@@ -6,12 +6,12 @@
  */
 return [
 
-    'failed' => 'Las credenciales no concuerdan con nuestros registros.',
-    'throttle' => 'Demasiados intentos fallidos de conexión. Por favor intente nuevamente en :seconds segundos.',
+    'failed' => 'Estas credenciales no concuerdan con nuestros registros.',
+    'throttle' => 'Demasiados intentos fallidos de inicio de sesión. Por favor intente nuevamente en :seconds segundos.',
 
     // Login & Register
     'sign_up' => 'Registrarse',
-    'log_in' => 'Acceder',
+    'log_in' => 'Iniciar sesión',
     'log_in_with' => 'Acceder con :socialDriver',
     'sign_up_with' => 'Registrarse con :socialDriver',
     'logout' => 'Salir',
@@ -91,7 +91,7 @@ return [
     'mfa_option_totp_title' => 'Aplicación móvil',
     'mfa_option_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitará una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'Códigos de Respaldo',
-    'mfa_option_backup_codes_desc' => 'Genera un conjunto de códigos de seguridad de un solo uso que ingresará al iniciar sesión para verificar su identidad. Asegúrese de guardarlos en un lugar seguro.',
+    'mfa_option_backup_codes_desc' => 'Genera un conjunto de códigos de respaldo de un único uso que ingresará al iniciar sesión para verificar su identidad. Asegúrese de guardarlos en un lugar seguro.',
     'mfa_gen_confirm_and_enable' => 'Confirmar y Activar',
     'mfa_gen_backup_codes_title' => 'Configuración de Códigos de Respaldo',
     'mfa_gen_backup_codes_desc' => 'Guarde la siguiente lista de códigos en un lugar seguro. Al acceder al sistema podrá usar uno de los códigos como un segundo mecanismo de autenticación.',
diff --git a/lang/es_AR/common.php b/lang/es_AR/common.php
index ebac11119..e30adbf69 100644
--- a/lang/es_AR/common.php
+++ b/lang/es_AR/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Crear',
     'update' => 'Actualizar',
     'edit' => 'Editar',
+    'archive' => 'Archivar',
+    'unarchive' => 'Desarchivar',
     'sort' => 'Ordenar',
     'move' => 'Mover',
     'copy' => 'Copiar',
diff --git a/lang/es_AR/editor.php b/lang/es_AR/editor.php
index 59c640ac0..94fd55b09 100644
--- a/lang/es_AR/editor.php
+++ b/lang/es_AR/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superíndice',
     'subscript' => 'Subíndice',
     'text_color' => 'Color del texto',
+    'highlight_color' => 'Color de resaltado',
     'custom_color' => 'Color personalizado',
     'remove_color' => 'Eliminar color',
     'background_color' => 'Color de fondo',
@@ -148,7 +149,7 @@ return [
     'url' => 'URL',
     'text_to_display' => 'Texto a mostrar',
     'title' => 'Título',
-    'browse_links' => 'Ver enlaces',
+    'browse_links' => 'Explorar enlaces',
     'open_link' => 'Abrir enlace',
     'open_link_in' => 'Abrir enlace en...',
     'open_link_current' => 'Ventana actual',
diff --git a/lang/es_AR/entities.php b/lang/es_AR/entities.php
index b96de3b27..1ae092de1 100644
--- a/lang/es_AR/entities.php
+++ b/lang/es_AR/entities.php
@@ -23,7 +23,7 @@ return [
     'meta_updated' => 'Actualizado el :timeLength',
     'meta_updated_name' => 'Actualizado el :timeLength por :user',
     'meta_owned_name' => 'Propiedad de :user',
-    'meta_reference_count' => 'Referido en :count página | Referido en :count paginas',
+    'meta_reference_count' => 'Referido en :count página | Referido en :count páginas',
     'entity_select' => 'Seleccione entidad',
     'entity_select_lack_permission' => 'No tiene los permisos necesarios para seleccionar este elemento',
     'images' => 'Imágenes',
@@ -40,21 +40,21 @@ return [
     'export_text' => 'Archivo de texto plano',
     'export_md' => 'Archivo Markdown',
     'export_zip' => 'ZIP portable',
-    'default_template' => 'Plantilla de página por defecto',
-    'default_template_explain' => 'Asigne una plantilla de página que se utilizará como contenido predeterminado para todas las páginas creadas en este elemento. Tenga en cuenta que esto sólo se utilizará si el creador de páginas tiene acceso a la plantilla de página elegida.',
+    'default_template' => 'Plantilla de página predeterminada',
+    'default_template_explain' => 'Asigne una plantilla de página que se utilizará como contenido predeterminado para todas las páginas creadas en este elemento. Tenga en cuenta que esto sólo se utilizará si el creador de las páginas tiene acceso a la plantilla de página elegida.',
     'default_template_select' => 'Seleccione una página de plantilla',
     'import' => 'Importar',
     'import_validate' => 'Validar importación',
-    'import_desc' => 'Importar libros, capítulos y páginas usando una exportación zip portable de la misma o distinta instancia. Seleccione un archivo ZIP para continuar. Después de que el archivo haya sido subido y validado, podrá configurar y confirmar la importación en la siguiente vista.',
+    'import_desc' => 'Importar libros, capítulos y páginas usando una exportación zip portable de la misma instancia u otra distinta. Seleccione un archivo ZIP para continuar. Después de que el archivo haya sido subido y validado, podrá configurar y confirmar la importación en la siguiente vista.',
     'import_zip_select' => 'Seleccione archivo ZIP a subir',
     'import_zip_validation_errors' => 'Se detectaron errores al validar el archivo ZIP proporcionado:',
     'import_pending' => 'Importaciones pendientes',
-    'import_pending_none' => 'No se han iniciado importaciones.',
-    'import_continue' => 'Continuar importación',
+    'import_pending_none' => 'No se iniciaron importaciones.',
+    'import_continue' => 'Continuar la importación',
     'import_continue_desc' => 'Revise el contenido que debe importarse del archivo ZIP subido. Cuando esté listo, ejecute la importación para añadir su contenido a este sistema. El archivo de importación ZIP subido se eliminará automáticamente al terminar la importación correctamente.',
     'import_details' => 'Detalles de la Importación',
-    'import_run' => 'Ejecutar Importación',
-    'import_size' => ':size tamaño archivo ZIP',
+    'import_run' => 'Ejecutar la importación',
+    'import_size' => ':size tamaño del archivo ZIP',
     'import_uploaded_at' => 'Subido :relativeTime',
     'import_uploaded_by' => 'Subido por',
     'import_location' => 'Ubicación de Importación',
@@ -62,11 +62,15 @@ return [
     'import_delete_confirm' => '¿Está seguro de que desea eliminar esta importación?',
     'import_delete_desc' => 'Esto eliminará el archivo ZIP de importación subido y no se puede deshacer.',
     'import_errors' => 'Errores de Importación',
-    'import_errors_desc' => 'Se han producido los siguientes errores durante el intento de importación:',
+    'import_errors_desc' => 'Se produjeron los siguientes errores durante el intento de importación:',
+    'breadcrumb_siblings_for_page' => 'Navegar por páginas del mismo nivel',
+    'breadcrumb_siblings_for_chapter' => 'Navegar por capítulos del mismo nivel',
+    'breadcrumb_siblings_for_book' => 'Navegar por libros del mismo nivel',
+    'breadcrumb_siblings_for_bookshelf' => 'Navegar por estantes del mismo nivel',
 
     // Permissions and restrictions
     'permissions' => 'Permisos',
-    'permissions_desc' => 'Establezca los permisos aquí para reemplazar los permisos por defecto proporcionados por los roles de usuario.',
+    'permissions_desc' => 'Establezca los permisos aquí para reemplazar los permisos predeterminados proporcionados por los roles de usuario.',
     'permissions_book_cascade' => 'Los permisos establecidos en los libros se aplicarán a los capítulos contenidos y las páginas contenidas, a menos que tengan sus propios permisos definidos.',
     'permissions_chapter_cascade' => 'Los permisos establecidos en los capítulos se aplicarán a las páginas contenidas, a menos que tengan sus propios permisos definidos.',
     'permissions_save' => 'Guardar permisos',
@@ -74,7 +78,7 @@ return [
     'permissions_role_everyone_else' => 'Todos los demás',
     'permissions_role_everyone_else_desc' => 'Establecer permisos para todos los roles no específicamente reemplazados.',
     'permissions_role_override' => 'Reemplazar permisos para el rol',
-    'permissions_inherit_defaults' => 'Heredar valores por defecto',
+    'permissions_inherit_defaults' => 'Heredar valores predeterminados',
 
     // Search
     'search_results' => 'Buscar resultados',
@@ -166,7 +170,7 @@ return [
     'books_search_this' => 'Buscar en este libro',
     'books_navigation' => 'Navegación de libro',
     'books_sort' => 'Organizar contenido de libro',
-    'books_sort_desc' => 'Mueve capítulos y páginas dentro de un libro para reorganizar su contenido. Se pueden añadir otros libros que permiten mover fácilmente capítulos y páginas entre libros. Opcionalmente, se puede establecer una regla de ordenación automática para ordenar automáticamente el contenido de este libro cuando haya cambios.',
+    'books_sort_desc' => 'Mueve capítulos y páginas dentro de un libro para reorganizar su contenido. Se pueden añadir otros libros que permiten mover fácilmente capítulos y páginas entre libros. Opcionalmente, se puede establecer una regla de ordenación automática para el contenido de este libro cuando haya cambios.',
     'books_sort_auto_sort' => 'Opción de ordenación automática',
     'books_sort_auto_sort_active' => 'Opción de ordenación activa: sortName',
     'books_sort_named' => 'Organizar libro :bookName',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Contenido Estable)',
     'pages_edit_switch_to_wysiwyg' => 'Cambiar a Editor WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Cambiar a nuevo editor WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(En alfa)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(En prueba beta)',
     'pages_edit_set_changelog' => 'Establecer cambios de registro',
     'pages_edit_enter_changelog_desc' => 'Introduzca una breve descripción de los cambios que ha realizado',
     'pages_edit_enter_changelog' => 'Entrar en cambio de registro',
@@ -268,8 +272,9 @@ return [
     'pages_md_insert_drawing' => 'Insertar Dibujo',
     'pages_md_show_preview' => 'Mostrar vista previa',
     'pages_md_sync_scroll' => 'Sincronizar desplazamiento de vista previa',
+    'pages_md_plain_editor' => 'Editor de texto plano',
     'pages_drawing_unsaved' => 'Encontrado dibujo sin guardar',
-    'pages_drawing_unsaved_confirm' => 'Se encontraron datos no guardados del dibujo de un intento de guardado fallido. ¿Desea restaurar y continuar editando el dibujo no guardado?',
+    'pages_drawing_unsaved_confirm' => 'Se encontraron datos del dibujo no guardados durante un intento de guardado fallido anterior. ¿Desea restaurar y continuar editando el dibujo no guardado?',
     'pages_not_in_chapter' => 'La página no esá en el capítulo',
     'pages_move' => 'Mover página',
     'pages_copy' => 'Copiar página',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comentario',
     'comments' => 'Comentarios',
     'comment_add' => 'Agregar comentario',
+    'comment_none' => 'No hay comentarios para mostrar',
     'comment_placeholder' => 'DEjar un comentario aquí',
-    'comment_count' => '{0} Sin Comentarios|{1} 1 Comentario|[2,*] :count Comentarios',
+    'comment_thread_count' => ':count Hilo de Comentarios|:count Hilos de Comentarios',
+    'comment_archived_count' => ':count Archivados',
+    'comment_archived_threads' => 'Hilos Archivados',
     'comment_save' => 'Guardar comentario',
     'comment_new' => 'Nuevo comentario',
     'comment_created' => 'comentado :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comentario borrado',
     'comment_created_success' => 'Comentario agregado',
     'comment_updated_success' => 'Comentario actualizado',
+    'comment_archive_success' => 'Comentario archivado',
+    'comment_unarchive_success' => 'Comentario no archivado',
+    'comment_view' => 'Ver comentario',
+    'comment_jump_to_thread' => 'Ir al hilo',
     'comment_delete_confirm' => '¿Está seguro que quiere borrar este comentario?',
     'comment_in_reply_to' => 'En respuesta a :commentId',
+    'comment_reference' => 'Referencia',
+    'comment_reference_outdated' => '(Obsoleto)',
     'comment_editor_explain' => 'Estos son los comentarios que se escribieron en esta página. Los comentarios se pueden añadir y administrar cuando se visualiza la página guardada.',
 
     // Revision
@@ -434,29 +448,29 @@ return [
     // References
     'references' => 'Referencias',
     'references_none' => 'No hay referencias a este elemento.',
-    'references_to_desc' => 'A continuación se muestran todas las páginas en el sistema que enlazan a este elemento.',
+    'references_to_desc' => 'A continuación se muestra todo el contenido conocido en el sistema que enlaza a este elemento.',
 
     // Watch Options
     'watch' => 'Suscribirme',
-    'watch_title_default' => 'Preferencias por defecto',
-    'watch_desc_default' => 'Revertir suscripciones a tus preferencias de notificación por defecto.',
+    'watch_title_default' => 'Preferencias Predeterminadas',
+    'watch_desc_default' => 'Revertir las suscripciones a solo tus preferencias de notificación predeterminadas.',
     'watch_title_ignore' => 'Ignorar',
-    'watch_desc_ignore' => 'Ignorar todas las notificaciones, incluyendo las de las preferencias a nivel de usuario.',
-    'watch_title_new' => 'Nuevas páginas',
+    'watch_desc_ignore' => 'Ignorar todas las notificaciones, incluyendo las de preferencias a nivel de usuario.',
+    'watch_title_new' => 'Páginas Nuevas',
     'watch_desc_new' => 'Notificar cuando se crea una nueva página dentro de este elemento.',
-    'watch_title_updates' => 'Todas las actualizaciones de páginas',
-    'watch_desc_updates' => 'Notificar todos los cambios de páginas y páginas nuevas.',
-    'watch_desc_updates_page' => 'Notificar todos los cambios en la página.',
-    'watch_title_comments' => 'Todas las actualizaciones de páginas y comentarios',
-    'watch_desc_comments' => 'Notificar sobre todas las páginas nuevas, cambios de página y nuevos comentarios.',
+    'watch_title_updates' => 'Todas las actualizaciones de página',
+    'watch_desc_updates' => 'Notificar todas las páginas nuevas y todos los cambios de página.',
+    'watch_desc_updates_page' => 'Notificar todos los cambios de página.',
+    'watch_title_comments' => 'Todas las actualizaciones y comentarios de página',
+    'watch_desc_comments' => 'Notificar todas las páginas nuevas, todos los cambios de página y todos los nuevos comentarios.',
     'watch_desc_comments_page' => 'Notificar los cambios en las páginas y los nuevos comentarios.',
-    'watch_change_default' => 'Cambiar preferencias de notificación por defecto',
-    'watch_detail_ignore' => 'Ignorar notificaciones',
+    'watch_change_default' => 'Cambiar preferencias de notificación predeterminadas',
+    'watch_detail_ignore' => 'Ignorando notificaciones',
     'watch_detail_new' => 'Suscripciones de nuevas páginas',
     'watch_detail_updates' => 'Suscripciones de nuevas páginas y actualizaciones de páginas',
     'watch_detail_comments' => 'Suscripciones de nuevas páginas, actualizaciones de páginas y comentarios',
     'watch_detail_parent_book' => 'Subscripciones por libro contenedor',
     'watch_detail_parent_book_ignore' => 'Ignorando a través del libro contenedor',
-    'watch_detail_parent_chapter' => 'Subscripciones por capítulo superior',
-    'watch_detail_parent_chapter_ignore' => 'Ignorar por capítulo superior',
+    'watch_detail_parent_chapter' => 'Subscripciones por capítulo contenedor',
+    'watch_detail_parent_chapter_ignore' => 'Ignorar por capítulo contenedor',
 ];
diff --git a/lang/es_AR/errors.php b/lang/es_AR/errors.php
index cb1bc1202..16a5b5467 100644
--- a/lang/es_AR/errors.php
+++ b/lang/es_AR/errors.php
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'No se puede borrar el único administrador',
     'users_cannot_delete_guest' => 'No se puede borrar el usuario invitado',
-    'users_could_not_send_invite' => 'No se pudo crear el usuario porque no se pudo enviar el correo de invitación',
+    'users_could_not_send_invite' => 'No se creó el usuario porque no se pudo enviar el correo de invitación',
 
     // Roles
     'role_cannot_be_edited' => 'Este rol no puede ser editado',
@@ -107,9 +107,9 @@ return [
 
     // Import
     'import_zip_cant_read' => 'No se pudo leer el archivo ZIP.',
-    'import_zip_cant_decode_data' => 'No se pudo encontrar y decodificar el archivo data.json. en el archivo ZIP.',
-    'import_zip_no_data' => 'Los datos del archivo ZIP no contienen ningún libro, capítulo o contenido de página.',
-    'import_validation_failed' => 'Error al validar la importación del ZIP con errores:',
+    'import_zip_cant_decode_data' => 'No se pudo encontrar ni decodificar el contenido del archivo ZIP data.json.',
+    'import_zip_no_data' => 'Los datos del archivo ZIP no tienen un libro, un capítulo o contenido de página en su contenido.',
+    'import_validation_failed' => 'Error al validar la importación del ZIP con los errores:',
     'import_zip_failed_notification' => 'Error al importar archivo ZIP.',
     'import_perms_books' => 'Le faltan los permisos necesarios para crear libros.',
     'import_perms_chapters' => 'Le faltan los permisos necesarios para crear capítulos.',
@@ -129,5 +129,5 @@ return [
     'maintenance_test_email_failure' => 'Error al enviar un email de prueba:',
 
     // HTTP errors
-    'http_ssr_url_no_match' => 'La URL no coincide con los hosts SSR permitidos',
+    'http_ssr_url_no_match' => 'La URL no coincide con los hosts SSR configurados como permitidos',
 ];
diff --git a/lang/es_AR/notifications.php b/lang/es_AR/notifications.php
index a75b30f55..fc89b2c25 100644
--- a/lang/es_AR/notifications.php
+++ b/lang/es_AR/notifications.php
@@ -5,11 +5,11 @@
 return [
 
     'new_comment_subject' => 'Nuevo comentario en la página: :pageName',
-    'new_comment_intro' => 'Un usuario ha comentado en una página de :appName:',
-    'new_page_subject' => 'Nueva página: :pageName',
-    'new_page_intro' => 'Una nueva página ha sido creada en :appName:',
+    'new_comment_intro' => 'Un usuario comentó en una página de :appName:',
+    'new_page_subject' => 'Página nueva: :pageName',
+    'new_page_intro' => 'Se creó una nueva página en :appName:',
     'updated_page_subject' => 'Página actualizada: :pageName',
-    'updated_page_intro' => 'Una página ha sido actualizada en :appName:',
+    'updated_page_intro' => 'Se actualizó una página en :appName:',
     'updated_page_debounce' => 'Para evitar una avalancha de notificaciones, durante un tiempo no se enviarán notificaciones sobre más ediciones de esta página por el mismo editor.',
 
     'detail_page_name' => 'Nombre de la página:',
diff --git a/lang/es_AR/preferences.php b/lang/es_AR/preferences.php
index 2bf3f3f96..11c872021 100644
--- a/lang/es_AR/preferences.php
+++ b/lang/es_AR/preferences.php
@@ -8,7 +8,7 @@ return [
     'my_account' => 'Mi cuenta',
 
     'shortcuts' => 'Atajos',
-    'shortcuts_interface' => 'Preferencias de acceso directo en la interfaz',
+    'shortcuts_interface' => 'Preferencias de atajos en la interfaz',
     'shortcuts_toggle_desc' => 'Aquí puede activar o desactivar los accesos rápidos de la interfaz, utilizados para la navegación y las acciones.',
     'shortcuts_customize_desc' => 'Puede personalizar cada uno de los atajos a continuación. Simplemente pulse la combinación de teclas deseada después de seleccionar la entrada para un atajo.',
     'shortcuts_toggle_label' => 'Atajos de teclado habilitados',
@@ -21,31 +21,31 @@ return [
 
     'notifications' => 'Preferencias de notificaciones',
     'notifications_desc' => 'Controle las notificaciones por correo electrónico que recibe cuando se realiza cierta actividad dentro del sistema.',
-    'notifications_opt_own_page_changes' => 'Notificar sobre los cambios en las páginas en las que soy propietario',
-    'notifications_opt_own_page_comments' => 'Notificar sobre comentarios en las páginas en las que soy propietario',
+    'notifications_opt_own_page_changes' => 'Notificar sobre los cambios en las páginas de las que soy propietario',
+    'notifications_opt_own_page_comments' => 'Notificar sobre comentarios en las páginas de las que soy propietario',
     'notifications_opt_comment_replies' => 'Notificar sobre respuestas a mis comentarios',
     'notifications_save' => 'Guardar preferencias',
-    'notifications_update_success' => '¡Se han actualizado las preferencias de notificaciones!',
-    'notifications_watched' => 'Elementos vistos e ignorados',
+    'notifications_update_success' => '¡Se actualizaron las preferencias de notificaciones!',
+    'notifications_watched' => 'Elementos vigilados e ignorados',
     'notifications_watched_desc' => 'A continuación se muestran los elementos que tienen preferencias personalizadas de monitorización. Para actualizar sus preferencias, vea el artículo y las opciones se mostrarán en la barra lateral.',
 
     'auth' => 'Acceso y seguridad',
     'auth_change_password' => 'Cambiar contraseña',
     'auth_change_password_desc' => 'Cambie la contraseña que utiliza para iniciar sesión en la aplicación. Debe tener al menos 8 caracteres.',
-    'auth_change_password_success' => '¡La contraseña ha sido actualizada!',
+    'auth_change_password_success' => '¡Se actualizó la contraseña!',
 
     'profile' => 'Detalles del perfil',
-    'profile_desc' => 'Administre los detalles de su cuenta que le representa a otros usuarios, además de los detalles que se utilizan para la comunicación y la personalización del sistema.',
-    'profile_view_public' => 'Ver perfil público',
+    'profile_desc' => 'Administre los detalles de su cuenta que lo representan ante otros usuarios, además de los detalles que se utilizan para la comunicación y la personalización del sistema.',
+    'profile_view_public' => 'Ver Perfil Público',
     'profile_name_desc' => 'Configure el nombre que será visible para otros usuarios del sistema a través de la actividad que realiza, y el contenido que posee.',
-    'profile_email_desc' => 'Este correo electrónico se utilizará para las notificaciones y, dependiendo de la autenticación activa del sistema, el acceso del sistema.',
+    'profile_email_desc' => 'Este correo electrónico se utilizará para las notificaciones y, dependiendo de la autenticación activa del sistema, para el acceso al sistema.',
     'profile_email_no_permission' => 'Lamentablemente no tiene permiso para cambiar su dirección de correo electrónico. Si desea cambiar esto, necesitará pedir a un administrador que lo cambie por usted.',
-    'profile_avatar_desc' => 'Seleccione una imagen pública que verán los demás en el sistema. Idealmente esta imagen debe ser cuadrada y alrededor de 256px de anchura y altura.',
-    'profile_admin_options' => 'Opciones de administrador',
-    'profile_admin_options_desc' => 'Opciones adicionales de administrador, como por ejemplo administrar asignaciones de rol, se pueden encontrar para su cuenta de usuario en el área de "Ajustes > Usuarios" de la aplicación.',
+    'profile_avatar_desc' => 'Seleccione una imagen que se utilizará para representarlo ante los demás en el sistema. Idealmente esta imagen debe ser cuadrada y alrededor de 256px de ancho y alto.',
+    'profile_admin_options' => 'Opciones de Administrador',
+    'profile_admin_options_desc' => 'Opciones adicionales de nivel de administrador, como aquellas para administrar asignaciones de rol, se pueden encontrar para su cuenta de usuario en el área de "Ajustes > Usuarios" de la aplicación.',
 
-    'delete_account' => 'Eliminar cuenta',
-    'delete_my_account' => 'Eliminar mi cuenta',
+    'delete_account' => 'Eliminar Cuenta',
+    'delete_my_account' => 'Eliminar Mi Cuenta',
     'delete_my_account_desc' => 'Esto eliminará completamente su cuenta de usuario del sistema. No podrá recuperar esta cuenta o revertir esta acción. El contenido que ha creado, como páginas creadas e imágenes subidas, permanecerá.',
     'delete_my_account_warning' => '¿Está seguro de que desea eliminar su cuenta?',
 ];
diff --git a/lang/es_AR/settings.php b/lang/es_AR/settings.php
index 30eb095d9..5cbcff86e 100644
--- a/lang/es_AR/settings.php
+++ b/lang/es_AR/settings.php
@@ -75,10 +75,10 @@ return [
     'reg_confirm_restrict_domain_placeholder' => 'Ninguna restricción establecida',
 
     // Sorting Settings
-    'sorting' => 'Ordenación',
-    'sorting_book_default' => 'Orden por defecto del libro',
+    'sorting' => 'Ordenando',
+    'sorting_book_default' => 'Orden predeterminado del libro',
     'sorting_book_default_desc' => 'Seleccione la regla de ordenación predeterminada para aplicar a nuevos libros. Esto no afectará a los libros existentes, y puede ser anulado por libro.',
-    'sorting_rules' => 'Reglas de ordenación',
+    'sorting_rules' => 'Reglas de Ordenación',
     'sorting_rules_desc' => 'Son operaciones de ordenación predefinidas que se pueden aplicar al contenido en el sistema.',
     'sort_rule_assigned_to_x_books' => 'Asignado a :count libro | Asignado a :count libros',
     'sort_rule_create' => 'Crear regla de ordenación',
@@ -86,13 +86,13 @@ return [
     'sort_rule_delete' => 'Eliminar regla de ordenación',
     'sort_rule_delete_desc' => 'Eliminar esta regla de ordenación del sistema. Los Libros que utilicen este tipo se revertirán a la ordenación manual.',
     'sort_rule_delete_warn_books' => 'Esta regla de ordenación se utiliza actualmente en :count libro(s). ¿Está seguro que desea eliminarla?',
-    'sort_rule_delete_warn_default' => 'Esta regla de ordenación se utiliza actualmente como predeterminado para los libros. ¿Está seguro de que desea eliminarla?',
+    'sort_rule_delete_warn_default' => 'Esta regla de ordenación se utiliza actualmente como predeterminada para los libros. ¿Está seguro de que desea eliminarla?',
     'sort_rule_details' => 'Detalles de la regla de ordenación',
     'sort_rule_details_desc' => 'Establezca un nombre para esta regla de ordenación, que aparecerá en las listas cuando los usuarios estén seleccionando un orden.',
     'sort_rule_operations' => 'Operaciones de ordenación',
     'sort_rule_operations_desc' => 'Configure las acciones de ordenación a realizar moviéndolas de la lista de operaciones disponibles. Al usarse, las operaciones se aplicarán en orden, de arriba a abajo. Cualquier cambio realizado aquí se aplicará a todos los libros asignados al guardar.',
     'sort_rule_available_operations' => 'Operaciones disponibles',
-    'sort_rule_available_operations_empty' => 'No hay operaciones restantes',
+    'sort_rule_available_operations_empty' => 'No hay operaciones pendientes',
     'sort_rule_configured_operations' => 'Operaciones configuradas',
     'sort_rule_configured_operations_empty' => 'Arrastrar/añadir operaciones desde la lista de "Operaciones disponibles"',
     'sort_rule_op_asc' => '(Asc)',
@@ -225,7 +225,7 @@ return [
     'users_send_invite_text' => 'Puede optar por enviar a este usuario un correo electrónico de invitación que les permita establecer su propia contraseña; de lo contrario, puede establecerla contraseña usted mismo.',
     'users_send_invite_option' => 'Enviar correo electrónico de invitación al usuario.',
     'users_external_auth_id' => 'ID externo de autenticación',
-    'users_external_auth_id_desc' => 'Cuando un sistema de autenticación externa está en uso (como SAML2, OIDC o LDAP) este es el ID que vincula este usuario de BookStack a la cuenta del sistema de autenticación. Puede ignorar este campo si utiliza la autenticación por defecto basada en correo electrónico.',
+    'users_external_auth_id_desc' => 'Cuando se usa un sistema de autenticación externa (como SAML2, OIDC o LDAP) este es el ID que vincula este usuario de BookStack a la cuenta del sistema de autenticación. Puede ignorar este campo si utiliza la autenticación por defecto basada en correo electrónico.',
     'users_password_warning' => 'Solo complete lo siguiente si desea cambiar la contraseña para este usuario.',
     'users_system_public' => 'Este usuario representa cualquier usuario invitado que visita la aplicación. No puede utilizarse para hacer login sino que es asignado automáticamente.',
     'users_delete' => 'Borrar usuario',
@@ -312,10 +312,10 @@ return [
     'licenses' => 'Licencias',
     'licenses_desc' => 'Esta página detalla información sobre la licencia de BookStack además de los proyectos y bibliotecas que se utilizan en BookStack. Muchos proyectos enumerados aquí pueden ser utilizados solo en un contexto de desarrollo.',
     'licenses_bookstack' => 'Licencia BookStack',
-    'licenses_php' => 'Licencias de la biblioteca PHP',
-    'licenses_js' => 'Licencias de la biblioteca JavaScript',
+    'licenses_php' => 'Licencias de Bibliotecas PHP',
+    'licenses_js' => 'Licencias de Bibliotecas JavaScript',
     'licenses_other' => 'Otras Licencias',
-    'license_details' => 'Datos de la licencia',
+    'license_details' => 'Detalles de la licencia',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -349,6 +349,7 @@ return [
         'lt' => 'Lituano',
         'lv' => 'Letón',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Holanda',
         'pl' => 'Polaco',
diff --git a/lang/et/common.php b/lang/et/common.php
index 3eb6b50b9..580cdf133 100644
--- a/lang/et/common.php
+++ b/lang/et/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Lisa',
     'update' => 'Uuenda',
     'edit' => 'Muuda',
+    'archive' => 'Arhiveeri',
+    'unarchive' => 'Taasta arhiivist',
     'sort' => 'Sorteeri',
     'move' => 'Liiguta',
     'copy' => 'Kopeeri',
diff --git a/lang/et/editor.php b/lang/et/editor.php
index e67e9b0f5..66561c3c8 100644
--- a/lang/et/editor.php
+++ b/lang/et/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Ülaindeks',
     'subscript' => 'Alaindeks',
     'text_color' => 'Teksti värv',
+    'highlight_color' => 'Esiletõstuvärv',
     'custom_color' => 'Kohandatud värv',
     'remove_color' => 'Eemalda värv',
     'background_color' => 'Taustavärv',
diff --git a/lang/et/entities.php b/lang/et/entities.php
index 87b625ed7..323985b2d 100644
--- a/lang/et/entities.php
+++ b/lang/et/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'See kustutab üleslaaditud ZIP-faili, ja seda ei saa tagasi võtta.',
     'import_errors' => 'Importimise vead',
     'import_errors_desc' => 'Importimisel esinesid järgmised vead:',
+    'breadcrumb_siblings_for_page' => 'Sirvi teisi lehti',
+    'breadcrumb_siblings_for_chapter' => 'Sirvi teisi peatükke',
+    'breadcrumb_siblings_for_book' => 'Sirvi teisi raamatuid',
+    'breadcrumb_siblings_for_bookshelf' => 'Sirvi teisi riiuleid',
 
     // Permissions and restrictions
     'permissions' => 'Õigused',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stabiilne sisu)',
     'pages_edit_switch_to_wysiwyg' => 'Kasuta WYSIWYG redaktorit',
     'pages_edit_switch_to_new_wysiwyg' => 'Kasuta uut tekstiredaktorit',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(alfatestimisel)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(beetatestimisel)',
     'pages_edit_set_changelog' => 'Muudatuste logi',
     'pages_edit_enter_changelog_desc' => 'Sisesta tehtud muudatuste lühikirjeldus',
     'pages_edit_enter_changelog' => 'Salvesta muudatuste logi',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Lisa joonis',
     'pages_md_show_preview' => 'Näita eelvaadet',
     'pages_md_sync_scroll' => 'Sünkrooni eelvaate kerimine',
+    'pages_md_plain_editor' => 'Lihttekstiredaktor',
     'pages_drawing_unsaved' => 'Leiti salvestamata joonis',
     'pages_drawing_unsaved_confirm' => 'Varasemast ebaõnnestunud salvestuskatsest leiti salvestamata joonis. Kas soovid salvestamata joonise taastada ja selle muutmist jätkata?',
     'pages_not_in_chapter' => 'Leht ei kuulu peatüki alla',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Kommentaar',
     'comments' => 'Kommentaarid',
     'comment_add' => 'Lisa kommentaar',
+    'comment_none' => 'Pole kommentaare, mida kuvada',
     'comment_placeholder' => 'Jäta siia kommentaar',
-    'comment_count' => '{0} Kommentaare pole|{1} 1 kommentaar|[2,*] :count kommentaari',
+    'comment_thread_count' => ':count kommentaarilõim|:count kommentaarilõime',
+    'comment_archived_count' => ':count arhiveeritud',
+    'comment_archived_threads' => 'Arhiveeritud lõimed',
     'comment_save' => 'Salvesta kommentaar',
     'comment_new' => 'Uus kommentaar',
     'comment_created' => 'kommenteeris :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Kommentaar kustutatud',
     'comment_created_success' => 'Kommentaar lisatud',
     'comment_updated_success' => 'Kommentaar muudetud',
+    'comment_archive_success' => 'Kommentaar arhiveeritud',
+    'comment_unarchive_success' => 'Kommentaar arhiivist taastatud',
+    'comment_view' => 'Vaata kommentaari',
+    'comment_jump_to_thread' => 'Hüppa lõime juurde',
     'comment_delete_confirm' => 'Kas oled kindel, et soovid selle kommentaari kustutada?',
     'comment_in_reply_to' => 'Vastus kommentaarile :commentId',
+    'comment_reference' => 'Viide',
+    'comment_reference_outdated' => '(Aegunud)',
     'comment_editor_explain' => 'Siin on lehele jäetud kommentaarid. Kommentaare saab lisada ja hallata salvestatud lehte vaadates.',
 
     // Revision
diff --git a/lang/et/settings.php b/lang/et/settings.php
index bfb8a51ac..003238cfe 100644
--- a/lang/et/settings.php
+++ b/lang/et/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba (leedu keel)',
         'lv' => 'Latviešu Valoda (läti keel)',
         'nb' => 'Norsk (Bokmål) (norra keel)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands (hollandi keel)',
         'pl' => 'Polski (poola keel)',
diff --git a/lang/eu/common.php b/lang/eu/common.php
index 35a67b349..f3696825e 100644
--- a/lang/eu/common.php
+++ b/lang/eu/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Sortu',
     'update' => 'Eguneratu',
     'edit' => 'Editatu',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Ordenatu',
     'move' => 'Mugitu',
     'copy' => 'Kopiatu',
diff --git a/lang/eu/editor.php b/lang/eu/editor.php
index f6974e056..3ec398cb7 100644
--- a/lang/eu/editor.php
+++ b/lang/eu/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Gain-eskripta',
     'subscript' => 'Azpi-script',
     'text_color' => 'Testuaren kolorea',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Kolore pertsonalizatua',
     'remove_color' => 'Kolorea ezabatu',
     'background_color' => 'Atzeko planoaren kolorea',
diff --git a/lang/eu/entities.php b/lang/eu/entities.php
index e13284216..cc51012d5 100644
--- a/lang/eu/entities.php
+++ b/lang/eu/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Baimenak',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Set Changelog',
     'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
     'pages_edit_enter_changelog' => 'Enter Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Txertatu marrazki berria',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Page is not in a chapter',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Iruzkina',
     'comments' => 'Iruzkinak',
     'comment_add' => 'Iruzkina gehitu',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Utzi iruzkin bat hemen',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Iruzkina gorde',
     'comment_new' => 'Iruzkin berria',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Iruzkina gehituta',
     'comment_updated_success' => 'Iruzkina gehituta',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Ziur zaude iruzkin hau ezabatu nahi duzula?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/eu/settings.php b/lang/eu/settings.php
index 2bc284c06..e93f3c964 100644
--- a/lang/eu/settings.php
+++ b/lang/eu/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/fa/activities.php b/lang/fa/activities.php
index cd7e52ed2..07eb78f37 100644
--- a/lang/fa/activities.php
+++ b/lang/fa/activities.php
@@ -6,7 +6,7 @@
 return [
 
     // Pages
-    'page_create'                 => 'تاریخ ایجاد',
+    'page_create'                 => 'صفحه ایجاد شد',
     'page_create_notification'    => 'صفحه با موفقیت ایجاد شد',
     'page_update'                 => 'به روزرسانی صفحه',
     'page_update_notification'    => 'صفحه با موفقیت به روزرسانی شد',
@@ -85,12 +85,12 @@ return [
     'webhook_delete_notification' => 'وب هوک با موفقیت حذف شد',
 
     // Imports
-    'import_create' => 'created import',
-    'import_create_notification' => 'Import successfully uploaded',
-    'import_run' => 'updated import',
-    'import_run_notification' => 'Content successfully imported',
-    'import_delete' => 'deleted import',
-    'import_delete_notification' => 'Import successfully deleted',
+    'import_create' => 'ورودی ایجاد شد',
+    'import_create_notification' => 'فایل با موفقیت آپلود شد',
+    'import_run' => 'آیتم واردشده بروزرسانی شد',
+    'import_run_notification' => 'محتوا با موفقیت انتقال یافت',
+    'import_delete' => 'آیتم ورودی حدف شده',
+    'import_delete_notification' => 'آیتم واردشده با موفقیت حذف شد',
 
     // Users
     'user_create' => 'کاربر ایجاد شده',
@@ -128,12 +128,12 @@ return [
     'comment_delete'              => 'نظر حذف شده',
 
     // Sort Rules
-    'sort_rule_create' => 'created sort rule',
-    'sort_rule_create_notification' => 'Sort rule successfully created',
-    'sort_rule_update' => 'updated sort rule',
-    'sort_rule_update_notification' => 'Sort rule successfully updated',
-    'sort_rule_delete' => 'deleted sort rule',
-    'sort_rule_delete_notification' => 'Sort rule successfully deleted',
+    'sort_rule_create' => 'قانون مرتب‌سازی ایجاد شد',
+    'sort_rule_create_notification' => 'قانون مرتب‌سازی با موفقیت ایجاد شد',
+    'sort_rule_update' => 'قانون مرتب‌سازی به‌روزرسانی شد',
+    'sort_rule_update_notification' => 'قانون مرتب‌سازی با موفقیت به‌روزرسانی شد',
+    'sort_rule_delete' => 'قانون مرتب‌سازی حذف شد',
+    'sort_rule_delete_notification' => 'قانون مرتب‌سازی با موفقیت حذف شد',
 
     // Other
     'permissions_update'          => 'به روزرسانی مجوزها',
diff --git a/lang/fa/auth.php b/lang/fa/auth.php
index c0902d4f9..c92f5f22b 100644
--- a/lang/fa/auth.php
+++ b/lang/fa/auth.php
@@ -91,7 +91,7 @@ return [
     'mfa_option_totp_title' => 'برنامه ی موبایل',
     'mfa_option_totp_desc' => 'برای استفاده از احراز هویت چند عاملی به یک برنامه موبایلی نیاز دارید که از TOTP پشتیبانی کند، مانند Google Authenticator، Authy یا Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'کدهای پشتیبان',
-    'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.',
+    'mfa_option_backup_codes_desc' => 'این فرایند مجموعه‌ای از کدهای پشتیبان یک‌بار مصرف تولید می‌کند که هنگام ورود به سامانه جهت تأیید هویت باید از آن‌ها استفاده کنید. توصیه می‌شود این کدها را در محلّی امن و محفوظ نگهداری نمایید.',
     'mfa_gen_confirm_and_enable' => 'تایید و فعال کنید',
     'mfa_gen_backup_codes_title' => 'راه اندازی کدهای پشتیبان',
     'mfa_gen_backup_codes_desc' => 'لیست کدهای زیر را در مکانی امن ذخیره کنید. هنگام دسترسی به سیستم، می توانید از یکی از کدها به عنوان مکانیزم احراز هویت دوم استفاده کنید.',
diff --git a/lang/fa/common.php b/lang/fa/common.php
index 765c1e627..49667a47c 100644
--- a/lang/fa/common.php
+++ b/lang/fa/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'ایجاد',
     'update' => 'به‌روز رسانی',
     'edit' => 'ويرايش',
+    'archive' => 'انتقال به بایگانی',
+    'unarchive' => 'فعّال‌سازی دوباره (خروج از بایگانی)',
     'sort' => 'مرتب سازی',
     'move' => 'جابجایی',
     'copy' => 'کپی',
@@ -109,5 +111,5 @@ return [
     'terms_of_service' => 'شرایط خدمات',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'جست‌وجو در :appName',
 ];
diff --git a/lang/fa/components.php b/lang/fa/components.php
index 576a50cca..18a01dc5d 100644
--- a/lang/fa/components.php
+++ b/lang/fa/components.php
@@ -34,8 +34,8 @@ return [
     'image_delete_success' => 'تصویر با موفقیت حذف شد',
     'image_replace' => 'جایگزینی تصویر',
     'image_replace_success' => 'تصویر با موفقیت به روز شد',
-    'image_rebuild_thumbs' => 'Regenerate Size Variations',
-    'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!',
+    'image_rebuild_thumbs' => 'بازتولید اندازه‌های گوناگونی از تصویر',
+    'image_rebuild_thumbs_success' => 'اندازه‌های گوناگونی از تصویر با موفقیت بازتولید شدند.',
 
     // Code Editor
     'code_editor' => 'ویرایش کد',
diff --git a/lang/fa/editor.php b/lang/fa/editor.php
index fa95bb10b..8a05571b8 100644
--- a/lang/fa/editor.php
+++ b/lang/fa/editor.php
@@ -13,7 +13,7 @@ return [
     'cancel' => 'لغو',
     'save' => 'ذخیره',
     'close' => 'بستن',
-    'apply' => 'Apply',
+    'apply' => 'اعمال',
     'undo' => 'برگشت',
     'redo' => 'از نو',
     'left' => 'چپ',
@@ -42,12 +42,13 @@ return [
     'callout_warning' => 'هشدار',
     'callout_danger' => 'خطر',
     'bold' => 'توپر',
-    'italic' => 'حروف کج(ایتالیک)',
+    'italic' => 'ایتالیک',
     'underline' => 'زیرخط',
     'strikethrough' => 'خط خورده',
     'superscript' => 'بالانویسی',
     'subscript' => 'پایین نویسی',
     'text_color' => 'رنگ متن',
+    'highlight_color' => 'رنگ هایلایت',
     'custom_color' => 'رنگ دلخواه',
     'remove_color' => 'حذف رنگ',
     'background_color' => 'رنگ زمینه',
@@ -82,9 +83,9 @@ return [
     'table_properties' => 'تنظیمات جدول',
     'table_properties_title' => 'تنظیمات جدول',
     'delete_table' => 'حذف جدول',
-    'table_clear_formatting' => 'Clear table formatting',
-    'resize_to_contents' => 'Resize to contents',
-    'row_header' => 'Row header',
+    'table_clear_formatting' => 'حذف قالب‌بندی جدول',
+    'resize_to_contents' => 'تغییر اندازه بر اساس محتوا',
+    'row_header' => 'عنوان سطر',
     'insert_row_before' => 'افزودن سطر به قبل',
     'insert_row_after' => 'افزودن سطر به بعد',
     'delete_row' => 'حذف سطر',
@@ -148,7 +149,7 @@ return [
     'url' => 'آدرس',
     'text_to_display' => 'متن جهت نمایش',
     'title' => 'عنوان',
-    'browse_links' => 'Browse links',
+    'browse_links' => 'مرور پیوندها',
     'open_link' => 'بازکردن لینک',
     'open_link_in' => 'باز کردن لینک در ...',
     'open_link_current' => 'پنجره کنونی',
@@ -165,8 +166,8 @@ return [
     'about' => 'درباره ویرایشگر',
     'about_title' => 'درباره ویرایشگر WYSIWYG',
     'editor_license' => 'مجوز و حق کپی رایت ویرایشگر',
-    'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
-    'editor_lexical_license_link' => 'Full license details can be found here.',
+    'editor_lexical_license' => 'این ویرایشگر بر پایه‌ی نسخه‌ای مشتق‌شده از «:lexicalLink» ساخته شده است که تحت مجوز MIT منتشر می‌شود.',
+    'editor_lexical_license_link' => 'جزئیات کامل مجوز را می‌توانید این‌جا مشاهده کنید.',
     'editor_tiny_license' => 'این ویرایشگر توسط :tinyLink و تحت مجوز MIT ساخته شده است.',
     'editor_tiny_license_link' => 'جزئیات کپی رایت و مجوز TinyMCE را می توانید در اینجا پیدا کنید.',
     'save_continue' => 'ذخیره صفحه و ادامه',
diff --git a/lang/fa/entities.php b/lang/fa/entities.php
index d994ed248..d7c0cc1c0 100644
--- a/lang/fa/entities.php
+++ b/lang/fa/entities.php
@@ -6,10 +6,10 @@
 return [
 
     // Shared
-    'recently_created' => 'اخیرا ایجاد شده',
-    'recently_created_pages' => 'صفحات اخیرا ایجاد شده',
-    'recently_updated_pages' => 'صفحاتی که اخیرا روزآمد شده‌اند',
-    'recently_created_chapters' => 'فصل های اخیرا ایجاد شده',
+    'recently_created' => 'تازه ایجاد شده',
+    'recently_created_pages' => 'صفحه‌های تازه ایجاد شده',
+    'recently_updated_pages' => 'صفحه‌های تازه به‌روزرسانی‌شده',
+    'recently_created_chapters' => 'فصل‌های تازه ایجاد شده',
     'recently_created_books' => 'کتاب های اخیرا ایجاد شده',
     'recently_created_shelves' => 'قفسه کتاب های اخیرا ایجاد شده',
     'recently_update' => 'اخیرا به روز شده',
@@ -39,36 +39,40 @@ return [
     'export_pdf' => 'فایل PDF',
     'export_text' => 'پرونده متنی ساده',
     'export_md' => 'راهنما مارک‌دون',
-    'export_zip' => 'Portable ZIP',
-    'default_template' => 'Default Page Template',
-    'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
-    'default_template_select' => 'Select a template page',
-    'import' => 'Import',
-    'import_validate' => 'Validate Import',
-    'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
-    'import_zip_select' => 'Select ZIP file to upload',
-    'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
-    'import_pending' => 'Pending Imports',
-    'import_pending_none' => 'No imports have been started.',
-    'import_continue' => 'Continue Import',
-    'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
-    'import_details' => 'Import Details',
-    'import_run' => 'Run Import',
-    'import_size' => ':size Import ZIP Size',
-    'import_uploaded_at' => 'Uploaded :relativeTime',
-    'import_uploaded_by' => 'Uploaded by',
-    'import_location' => 'Import Location',
-    'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
-    'import_delete_confirm' => 'Are you sure you want to delete this import?',
-    'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
-    'import_errors' => 'Import Errors',
-    'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'export_zip' => 'فایل فشرده‌ی زیپ',
+    'default_template' => 'قالب پیش‌فرض صفحه',
+    'default_template_explain' => 'قالبی برای صفحه تعیین کنید که به‌عنوان محتوای پیش‌فرض در تمام صفحاتی که در این مورد ایجاد می‌شوند، به‌کار رود. توجه داشته باشید این قالب تنها در صورتی اعمال می‌شود که سازندهٔ صفحه به صفحهٔ قالب انتخاب‌شده دسترسی نمایشی داشته باشد.',
+    'default_template_select' => 'انتخاب صفحهٔ قالب',
+    'import' => 'وارد کردن',
+    'import_validate' => 'اعتبارسنجی آیتم‌های واردشده',
+    'import_desc' => 'می‌توانید کتاب‌ها، فصل‌ها و صفحه‌ها را با استفاده از یک فایل فشردهٔ ZIP وارد کنید که از همین سامانه یا نمونه‌ای دیگر استخراج شده است. برای ادامه، فایل ZIP را انتخاب و بارگذاری کنید. پس از بارگذاری و اعتبارسنجی، در مرحلهٔ بعد می‌توانید تنظیمات ورود را انجام داده و آن را تأیید کنید.',
+    'import_zip_select' => 'انتخاب فایل ZIP برای بارگذاری',
+    'import_zip_validation_errors' => 'هنگام اعتبارسنجی فایل ZIP ارائه‌شده، خطاهایی شناسایی شد:',
+    'import_pending' => 'ورودی‌های در انتظار انتقال',
+    'import_pending_none' => 'هیچ انتقال ورودی آغاز نشده است.',
+    'import_continue' => 'ادامه فرآیند انتقال ورودی',
+    'import_continue_desc' => 'محتوای فایل ZIP بارگذاری‌شده را که قرار است وارد سامانه شود، مرور کنید. پس از اطمینان از صحت آن، انتقال را آغاز نمایید تا محتوا به این سامانه افزوده شود. توجه داشته باشید که پس از انتقال موفق، فایل ZIP بارگذاری‌شده به‌صورت خودکار حذف خواهد شد.',
+    'import_details' => 'جزئیات انتقال ورودی',
+    'import_run' => 'شروع فرایند انتقال ورودی',
+    'import_size' => 'حجم فایل ZIP واردشده:size',
+    'import_uploaded_at' => 'زمان بارگذاری:relativeTime',
+    'import_uploaded_by' => 'بارگذاری شده توسط',
+    'import_location' => 'مکان انتقال',
+    'import_location_desc' => 'برای محتوای واردشده، مقصدی انتخاب کنید. برای ایجاد محتوا در آن مقصد، داشتن مجوزهای لازم ضروری است.',
+    'import_delete_confirm' => 'مطمئن هستید که می‌خواهید آیتم واردشده را حدف کنید؟',
+    'import_delete_desc' => 'با انجام این کار، فایل ZIP واردشده حذف می‌شود و این عمل بازگشت‌ناپذیر است.',
+    'import_errors' => 'خطای انتقال ورودی',
+    'import_errors_desc' => 'در جریان تلاش برای انتقال ورودی، خطاهای زیر رخ داد:',
+    'breadcrumb_siblings_for_page' => 'پیمایش صفحات هم‌سطح',
+    'breadcrumb_siblings_for_chapter' => 'پیمایش فصل‌های هم‌سطح',
+    'breadcrumb_siblings_for_book' => 'پیمایش کتاب‌های هم‌سطح',
+    'breadcrumb_siblings_for_bookshelf' => 'پیمایش قفسه‌های هم‌سطح',
 
     // Permissions and restrictions
     'permissions' => 'مجوزها',
     'permissions_desc' => 'مجوزها را در اینجا تنظیم کنید تا مجوزهای پیش فرض تنظیم شده برای نقش های کاربر را لغو کنید.',
-    'permissions_book_cascade' => 'مجوزهای تنظیم‌شده روی کتاب‌ها به‌طور خودکار به فصل‌ها و صفحات داخل آن اختصاص داده می‌شوند، مگر اینکه مجوزهای اختصاصی برای آن‌ها (فصل‌ها و صفحات) تعریف شده باشد.',
-    'permissions_chapter_cascade' => 'مجوزهای تنظیم‌شده روی فصل‌ها به‌طور خودکار به صفحات داخل آن اختصاص داده می‌شوند، مگر اینکه مجوزهای اختصاصی برای آن‌ها (صفحات) تعریف شده باشد.',
+    'permissions_book_cascade' => 'مجوزهای تنظیم‌شده روی کتاب‌ها به‌طور خودکار به فصل‌ها و صفحات داخل آن اختصاص داده می‌شوند، مگر اینکه مجوزهای اختصاصی برای آن‌ها تعریف شده باشد.',
+    'permissions_chapter_cascade' => 'مجوزهای تنظیم‌شده روی فصل‌ها به‌طور خودکار به صفحات داخل آن اختصاص داده می‌شوند، مگر اینکه مجوزهای اختصاصی برای آن‌ها تعریف شده باشد.',
     'permissions_save' => 'ذخيره مجوزها',
     'permissions_owner' => 'مالک',
     'permissions_role_everyone_else' => 'سایر کاربران',
@@ -78,7 +82,7 @@ return [
 
     // Search
     'search_results' => 'نتایج جستجو',
-    'search_total_results_found' => 'نتیجه یافت شد :count | نتایج یافت شده :count',
+    'search_total_results_found' => 'نتیجه یافت شد:count | نتایج یافت شده:count',
     'search_clear' => 'پاک کردن جستجو',
     'search_no_pages' => 'هیچ صفحه ای با این جستجو مطابقت ندارد',
     'search_for_term' => 'جستجو برای :term',
@@ -151,7 +155,7 @@ return [
     'books_delete' => 'حذف کتاب',
     'books_delete_named' => 'حذف کتاب:bookName',
     'books_delete_explain' => 'با این کار کتابی با نام \':bookName\' حذف می شود. تمام صفحات و فصل ها حذف خواهند شد.',
-    'books_delete_confirmation' => 'آیا مطمئن هستید که می خواهید این کتاب را حذف کنید؟',
+    'books_delete_confirmation' => 'آیا مطمئن هستید که می خواهید این کتاب را حذف کنید?',
     'books_edit' => 'ویرایش کتاب',
     'books_edit_named' => 'ویرایش کتاب:bookName',
     'books_form_book_name' => 'نام کتاب',
@@ -165,14 +169,14 @@ return [
     'books_permissions_active' => 'مجوزهای کتاب فعال است',
     'books_search_this' => 'این کتاب را جستجو کنید',
     'books_navigation' => 'ناوبری کتاب',
-    'books_sort' => 'مرتب سازی مطالب کتاب',
-    'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.',
-    'books_sort_auto_sort' => 'Auto Sort Option',
-    'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName',
+    'books_sort' => 'مرتب‌سازی مطالب کتاب',
+    'books_sort_desc' => 'برای سامان‌دهی محتوای یک کتاب، می‌توانید فصل‌ها و صفحات آن را جابه‌جا کنید. همچنین می‌توانید کتاب‌های دیگری بیفزایید تا جابه‌جایی فصل‌ها و صفحات میان کتاب‌ها آسان شود. در صورت تمایل، می‌توانید قاعده‌ای برای مرتب‌سازی خودکار تعیین کنید تا محتوای کتاب در صورت ایجاد تغییرات، به طور خودکار مرتب شود.',
+    'books_sort_auto_sort' => 'گزینه مرتب‌سازی خودکار',
+    'books_sort_auto_sort_active' => 'مرتب‌سازی خودکار با قاعده: :sortName فعال است',
     'books_sort_named' => 'مرتب‌سازی کتاب:bookName',
-    'books_sort_name' => 'مرتب سازی بر اساس نام',
-    'books_sort_created' => 'مرتب سازی بر اساس تاریخ ایجاد',
-    'books_sort_updated' => 'مرتب سازی بر اساس تاریخ به روز رسانی',
+    'books_sort_name' => 'مرتب‌سازی بر اساس نام',
+    'books_sort_created' => 'مرتب‌سازی بر اساس تاریخ ایجاد',
+    'books_sort_updated' => 'مرتب‌سازی بر اساس تاریخ به روز رسانی',
     'books_sort_chapters_first' => 'فصل اول',
     'books_sort_chapters_last' => 'فصل آخر',
     'books_sort_show_other' => 'نمایش کتاب‌های دیگر',
@@ -230,7 +234,7 @@ return [
     'pages_delete_draft' => 'حذف صفحه پیش نویس',
     'pages_delete_success' => 'صفحه حذف شد',
     'pages_delete_draft_success' => 'صفحه پیش نویس حذف شد',
-    'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.',
+    'pages_delete_warning_template' => 'این صفحه هم‌اکنون به‌عنوان قالب پیش‌فرض صفحه برای یک کتاب یا فصل در حال استفاده است. پس از حذف این صفحه، کتاب‌ها یا فصل‌های مربوط دیگر قالب پیش‌فرض صفحه نخواهند داشت.',
     'pages_delete_confirm' => 'آیا مطمئن هستید که می خواهید این صفحه را حذف کنید؟',
     'pages_delete_draft_confirm' => 'آیا مطمئن هستید که می خواهید این صفحه پیش نویس را حذف کنید؟',
     'pages_editing_named' => 'ویرایش صفحه :pageName',
@@ -239,7 +243,7 @@ return [
     'pages_edit_draft' => 'ویرایش پیش نویس صفحه',
     'pages_editing_draft' => 'در حال ویرایش پیش نویس',
     'pages_editing_page' => 'در حال ویرایش صفحه',
-    'pages_edit_draft_save_at' => 'پیش نویس ذخیره شده در',
+    'pages_edit_draft_save_at' => 'پیش نویس ذخیره شده در ',
     'pages_edit_delete_draft' => 'حذف پیش نویس',
     'pages_edit_delete_draft_confirm' => 'آیا از حذف تغییرات صفحه پیش‌نویس اطمینان دارید؟ تمامی تغییرات‌تان، از آخرین ذخیره‌سازی کامل، از بین خواهد رفت و ویرایش‌گر به آخرین وضعیت پیش‌نویس ذخیره شده بازگردانی خواهد شد.',
     'pages_edit_discard_draft' => 'دور انداختن پیش نویس',
@@ -247,8 +251,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(مطالب تمیز)',
     'pages_edit_switch_to_markdown_stable' => '(محتوای پایدار)',
     'pages_edit_switch_to_wysiwyg' => 'به ویرایشگر WYSIWYG بروید',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'تغییر به ویرایشگر جدید WYSIWYG',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(در مرحله آزمایش بتا)',
     'pages_edit_set_changelog' => 'تنظیم تغییرات',
     'pages_edit_enter_changelog_desc' => 'توضیح مختصری از تغییراتی که ایجاد کرده اید وارد کنید',
     'pages_edit_enter_changelog' => 'وارد کردن تغییرات',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'درج طرح',
     'pages_md_show_preview' => 'دیدن پیش نمایش',
     'pages_md_sync_scroll' => 'هماهنگ سازی اسکرول پیش نمایش',
+    'pages_md_plain_editor' => 'ویرایشگر متن ساده',
     'pages_drawing_unsaved' => 'نقاشی ذخیره نشده پیدا شد',
     'pages_drawing_unsaved_confirm' => 'نسخه‌ای ذخیره‌نشده از طراحی‌های قبلی پیدا شد. آیا می‌خواهید این طراحی ذخیره‌نشده را بازیابی کنید و به ویرایش آن ادامه دهید؟',
     'pages_not_in_chapter' => 'صفحه در یک فصل نیست',
@@ -301,9 +306,9 @@ return [
     'pages_pointer_enter_mode' => 'ورود به حالت انتخاب قسمت',
     'pages_pointer_label' => 'گزینه‌های قسمت صفحه',
     'pages_pointer_permalink' => 'لینک ثابت قسمت صفحه',
-    'pages_pointer_include_tag' => 'Page Section Include Tag',
-    'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag',
-    'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink',
+    'pages_pointer_include_tag' => 'افزودن برچسب برای این بخش از صفحه',
+    'pages_pointer_toggle_link' => 'حالت پیوند دائمی؛ برای مشاهده برچسب افزوده شده، کلیک کنید',
+    'pages_pointer_toggle_include' => 'حالت افزودن برچسب؛ برای نمایش پیوند دائمی کلیک کنید',
     'pages_permissions_active' => 'مجوزهای صفحه فعال است',
     'pages_initial_revision' => 'انتشار اولیه',
     'pages_references_update_revision' => 'به‌روز‌رسانی خودکار لینک‌های داخلی سیستم',
@@ -324,7 +329,7 @@ return [
     'pages_is_template' => 'الگوی صفحه',
 
     // Editor Sidebar
-    'toggle_sidebar' => 'Toggle Sidebar',
+    'toggle_sidebar' => 'نمایش/پنهان‌سازی نوار کناری',
     'page_tags' => 'برچسب‌های صفحه',
     'chapter_tags' => 'برچسب‌های فصل',
     'book_tags' => 'برچسب های کتاب',
@@ -392,8 +397,11 @@ return [
     'comment' => 'اظهار نظر',
     'comments' => 'نظرات',
     'comment_add' => 'افزودن توضیح',
+    'comment_none' => 'نظری برای نمایش وجود ندارد',
     'comment_placeholder' => 'اینجا نظر بدهید',
-    'comment_count' => '{0} بدون نظر|{1} 1 نظر|[2,*] :count نظرات',
+    'comment_thread_count' => ':count رشته گفت‌وگو',
+    'comment_archived_count' => ':count مورد بایگانی‌شده',
+    'comment_archived_threads' => 'رشته‌ گفت‌وگوهای بایگانی‌شده',
     'comment_save' => 'ذخیره نظر',
     'comment_new' => 'نظر جدید',
     'comment_created' => ':createDiff نظر داد',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'نظر حذف شد',
     'comment_created_success' => 'نظر اضافه شد',
     'comment_updated_success' => 'نظر به روز شد',
+    'comment_archive_success' => 'نظر بایگانی شد',
+    'comment_unarchive_success' => 'نظر از بایگانی خارج شد',
+    'comment_view' => 'دیدن نظر',
+    'comment_jump_to_thread' => 'رفتن به رشته گفت‌وگو',
     'comment_delete_confirm' => 'آیا مطمئن هستید که می خواهید این نظر را حذف کنید؟',
     'comment_in_reply_to' => 'در پاسخ به :commentId',
+    'comment_reference' => 'مرجع',
+    'comment_reference_outdated' => '(نسخه قدیمی)',
     'comment_editor_explain' => 'در اینجا نظراتی که در این صفحه گذاشته شده است، مشاهده می‌شود. نظرات را می‌توان در هنگام مشاهده صفحه ذخیره شده، اضافه و مدیریت کرد.',
 
     // Revision
diff --git a/lang/fa/errors.php b/lang/fa/errors.php
index c4bc6963a..9d5257fcf 100644
--- a/lang/fa/errors.php
+++ b/lang/fa/errors.php
@@ -18,10 +18,10 @@ return [
     'ldap_fail_anonymous' => 'دسترسی LDAP با استفاده از صحافی ناشناس انجام نشد',
     'ldap_fail_authed' => 'دسترسی به LDAP با استفاده از جزئیات داده شده و رمز عبور انجام نشد',
     'ldap_extension_not_installed' => 'افزونه PHP LDAP نصب نشده است',
-    'ldap_cannot_connect' => 'اتصال به سرور LDAP امکان پذیر نیست، اتصال اولیه برقرار نشد',
+    'ldap_cannot_connect' => 'اتصال به سرور LDAP امکان‌پذیر نیست، اتصال اولیه برقرار نشد',
     'saml_already_logged_in' => 'قبلا وارد سیستم شده اید',
     'saml_no_email_address' => 'آدرس داده ای برای این کاربر در داده های ارائه شده توسط سیستم احراز هویت خارجی یافت نشد',
-    'saml_invalid_response_id' => 'درخواست از سیستم احراز هویت خارجی توسط فرایندی که توسط این نرم افزار آغاز شده است شناخته نمی شود. بازگشت به سیستم پس از ورود به سیستم می تواند باعث این مسئله شود.',
+    'saml_invalid_response_id' => 'درخواست ارسال‌شده از سامانه احراز هویت خارجی، توسط فرآیند آغازشده از سوی این نرم‌افزار شناسایی نشد. ممکن است بازگشت به صفحه قبل پس از ورود، موجب ایجاد این مشکل شده باشد.',
     'saml_fail_authed' => 'ورود به سیستم :system انجام نشد، سیستم مجوز موفقیت آمیز ارائه نکرد',
     'oidc_already_logged_in' => 'قبلا وارد شده اید',
     'oidc_no_email_address' => 'آدرس ایمیلی برای این کاربر در داده های ارائه شده توسط سیستم احراز هویت خارجی یافت نشد',
@@ -43,7 +43,7 @@ return [
     'path_not_writable' => 'مسیر فایل :filePath را نمی توان در آن آپلود کرد. مطمئن شوید که روی سرور قابل نوشتن است.',
     'cannot_get_image_from_url' => 'نمی توان تصویر را از :url دریافت کرد',
     'cannot_create_thumbs' => 'سرور نمی تواند تصاویر کوچک ایجاد کند. لطفاً بررسی کنید که پسوند GD PHP را نصب کرده اید.',
-    'server_upload_limit' => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.',
+    'server_upload_limit' => 'سرور اجازه آپلود با این حجم را نمی دهد. لطفا فایلی با حجم کم‌تر را امتحان کنید.',
     'server_post_limit' => 'سرور نمی‌تواند داده مقادیر ارائه شده داده را دریافت کند. با مقدار کمتر و فایل کوچکتر دوباره امتحان کنید.',
     'uploaded'  => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.',
 
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'شما نمی توانید تنها ادمین را حذف کنید',
     'users_cannot_delete_guest' => 'شما نمی توانید کاربر مهمان را حذف کنید',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'امکان ایجاد کاربر وجود ندارد؛ زیرا ارسال ایمیل دعوت با خطا مواجه شد.',
 
     // Roles
     'role_cannot_be_edited' => 'این نقش قابل ویرایش نیست',
@@ -106,16 +106,16 @@ return [
     'back_soon' => 'به زودی پشتیبان خواهد شد.',
 
     // Import
-    'import_zip_cant_read' => 'Could not read ZIP file.',
-    'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
-    'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
-    'import_validation_failed' => 'Import ZIP failed to validate with errors:',
-    'import_zip_failed_notification' => 'Failed to import ZIP file.',
-    'import_perms_books' => 'You are lacking the required permissions to create books.',
-    'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
-    'import_perms_pages' => 'You are lacking the required permissions to create pages.',
-    'import_perms_images' => 'You are lacking the required permissions to create images.',
-    'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
+    'import_zip_cant_read' => 'امکان ایجاد کاربر وجود ندارد؛ زیرا ارسال ایمیل دعوت با خطا مواجه شد.',
+    'import_zip_cant_decode_data' => 'محتوای data.json در فایل ZIP پیدا یا رمزگشایی نشد.',
+    'import_zip_no_data' => 'داده‌های فایل ZIP فاقد محتوای کتاب، فصل یا صفحه مورد انتظار است.',
+    'import_validation_failed' => 'اعتبارسنجی فایل ZIP واردشده با خطا مواجه شد:',
+    'import_zip_failed_notification' => ' فایل ZIP وارد نشد.',
+    'import_perms_books' => 'شما مجوز لازم برای ایجاد کتاب را ندارید.',
+    'import_perms_chapters' => 'شما مجوز لازم برای ایجاد فصل را ندارید.',
+    'import_perms_pages' => 'شما مجوز لازم برای ایجاد صفحه را ندارید.',
+    'import_perms_images' => 'شما مجوز لازم برای ایجاد تصویر را ندارید.',
+    'import_perms_attachments' => 'شما مجوز لازم برای ایجاد پیوست را ندارید.',
 
     // API errors
     'api_no_authorization_found' => 'هیچ نشانه مجوزی در درخواست یافت نشد',
diff --git a/lang/fa/notifications.php b/lang/fa/notifications.php
index 12f4230a1..4595fd825 100644
--- a/lang/fa/notifications.php
+++ b/lang/fa/notifications.php
@@ -22,6 +22,6 @@ return [
     'action_view_comment' => 'مشاهده نظر',
     'action_view_page' => 'مشاهده صفحه',
 
-    'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.',
+    'footer_reason' => 'این اعلان برای شما ارسال شده است، زیرا پیوند (:link) فعالیتی از این نوع را برای این مورد پوشش می‌دهد.',
     'footer_reason_link' => 'تنظیمات اطلاع‌رسانی شما',
 ];
diff --git a/lang/fa/settings.php b/lang/fa/settings.php
index 09783312e..e4cb9018e 100644
--- a/lang/fa/settings.php
+++ b/lang/fa/settings.php
@@ -15,7 +15,7 @@ return [
     // App Settings
     'app_customization' => 'سفارشی‌سازی',
     'app_features_security' => 'ویژگی‌ها و امنیت',
-    'app_name' => 'نام نرم افزار',
+    'app_name' => 'نام نرم‌افزار',
     'app_name_desc' => 'این نام در هدر و در هر ایمیل ارسال شده توسط سیستم نشان داده شده است.',
     'app_name_header' => 'نمایش نام در هدر',
     'app_public_access' => 'دسترسی عمومی',
@@ -75,34 +75,34 @@ return [
     'reg_confirm_restrict_domain_placeholder' => 'بدون محدودیت',
 
     // Sorting Settings
-    'sorting' => 'Sorting',
-    'sorting_book_default' => 'Default Book Sort',
-    'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.',
-    'sorting_rules' => 'Sort Rules',
-    'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.',
-    'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books',
-    'sort_rule_create' => 'Create Sort Rule',
-    'sort_rule_edit' => 'Edit Sort Rule',
-    'sort_rule_delete' => 'Delete Sort Rule',
-    'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.',
-    'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?',
-    'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?',
-    'sort_rule_details' => 'Sort Rule Details',
-    'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.',
-    'sort_rule_operations' => 'Sort Operations',
-    'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.',
-    'sort_rule_available_operations' => 'Available Operations',
-    'sort_rule_available_operations_empty' => 'No operations remaining',
-    'sort_rule_configured_operations' => 'Configured Operations',
-    'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list',
-    'sort_rule_op_asc' => '(Asc)',
-    'sort_rule_op_desc' => '(Desc)',
-    'sort_rule_op_name' => 'Name - Alphabetical',
-    'sort_rule_op_name_numeric' => 'Name - Numeric',
-    'sort_rule_op_created_date' => 'Created Date',
-    'sort_rule_op_updated_date' => 'Updated Date',
-    'sort_rule_op_chapters_first' => 'Chapters First',
-    'sort_rule_op_chapters_last' => 'Chapters Last',
+    'sorting' => 'مرتب‌سازی',
+    'sorting_book_default' => 'مرتب‌سازی پیش‌فرض کتاب',
+    'sorting_book_default_desc' => 'قانون پیش‌فرض مرتب‌سازی را برای کتاب‌های جدید انتخاب کنید. تغییر قانون بر ترتیب کتاب‌های موجود تأثیری ندارد و می‌تواند برای هر کتاب به‌صورت جداگانه تغییر یابد.',
+    'sorting_rules' => 'قوانین مرتب‌سازی',
+    'sorting_rules_desc' => 'این‌ها عملیات مرتب‌سازی از پیش تعریف‌شده‌ای هستند که می‌توانید آن‌ها را بر محتوای سیستم اعمال کنید.',
+    'sort_rule_assigned_to_x_books' => 'اختصاص داده شده به :count کتاب',
+    'sort_rule_create' => 'ایجاد قانون مرتب‌سازی',
+    'sort_rule_edit' => 'ویرایش قانون مرتب‌سازی',
+    'sort_rule_delete' => 'حذف قانون مرتب‌سازی',
+    'sort_rule_delete_desc' => 'این قانون مرتب‌سازی را از سیستم حذف کنید. کتاب‌هایی که از این شیوه مرتب‌سازی استفاده می‌کنند، به حالت مرتب‌سازی دستی بازخواهند گشت.',
+    'sort_rule_delete_warn_books' => 'در حال حاضر این قانون مرتب‌سازی برای :count کتاب به‌کار می‌رود. آیا مطمئن هستید که می‌خواهید آن را حذف کنید؟',
+    'sort_rule_delete_warn_default' => 'این قانون مرتب‌سازی هم‌اکنون به عنوان پیش‌فرض کتاب‌ها تعیین شده است. آیا مطمئن هستید که می‌خواهید آن را حذف کنید؟',
+    'sort_rule_details' => 'جزئیات قانون مرتب‌سازی',
+    'sort_rule_details_desc' => 'برای این قانون مرتب‌سازی یک نام انتخاب کنید. این نام هنگام انتخاب شیوه مرتب‌سازی در فهرست‌ها نمایش داده خواهد شد.',
+    'sort_rule_operations' => 'عملیات مرتب‌سازی',
+    'sort_rule_operations_desc' => 'عملیات مرتب‌سازی را پیکربندی کنید. برای این منظور، آن‌ها را در فهرست عملیاتِ در دسترس جابه‌جا کنید تا ترتیب اجرای آن‌ها مشخص شود. هنگام استفاده، این عملیات به‌ترتیب از بالا به پایین اعمال خواهند شد. هر تغییری که در این بخش ایجاد کنید، پس از ذخیره، برای همه کتاب‌های اختصاص‌یافته اجرا می‌شود.',
+    'sort_rule_available_operations' => 'عملیات موجود',
+    'sort_rule_available_operations_empty' => 'عملیاتی باقی نمانده است',
+    'sort_rule_configured_operations' => 'عملیات پیکربندی‌شده',
+    'sort_rule_configured_operations_empty' => 'عملیات را از فهرست «عملیات موجود» حذف یا اضافه کنید',
+    'sort_rule_op_asc' => '(صعودی)',
+    'sort_rule_op_desc' => '(نزولی)',
+    'sort_rule_op_name' => 'نام - الفبایی',
+    'sort_rule_op_name_numeric' => 'نام - عددی',
+    'sort_rule_op_created_date' => 'تاریخ ایجاد',
+    'sort_rule_op_updated_date' => 'تاریخ به‌روزرسانی',
+    'sort_rule_op_chapters_first' => 'ابتدا فصل‌ها',
+    'sort_rule_op_chapters_last' => 'فصل‌ها در آخر',
 
     // Maintenance settings
     'maint' => 'نگهداری',
@@ -192,7 +192,7 @@ return [
     'role_access_api' => 'دسترسی به API سیستم',
     'role_manage_settings' => 'تنظیمات برنامه را مدیریت کنید',
     'role_export_content' => 'صادرات محتوا',
-    'role_import_content' => 'Import content',
+    'role_import_content' => 'وارد کردن محتوا',
     'role_editor_change' => 'تغییر ویرایشگر صفحه',
     'role_notifications' => 'دریافت و مدیریت اعلان‌ها',
     'role_asset' => 'مجوزهای دارایی',
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/fa/validation.php b/lang/fa/validation.php
index 10432471f..e1c69ece9 100644
--- a/lang/fa/validation.php
+++ b/lang/fa/validation.php
@@ -105,10 +105,10 @@ return [
     'url'                  => ':attribute معتبر نمی‌باشد.',
     'uploaded'             => 'بارگذاری فایل :attribute موفقیت آمیز نبود.',
 
-    'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
-    'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
-    'zip_model_expected' => 'Data object expected but ":type" found.',
-    'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
+    'zip_file' => 'ویژگی :attribute باید به یک فایل درون پرونده فشرده شده اشاره کند.',
+    'zip_file_mime' => 'ویژگی :attribute باید به فایلی با نوع :validTypes اشاره کند، اما نوع یافت‌شده :foundType است.',
+    'zip_model_expected' => 'سیستم در این بخش انتظار دریافت یک شیء داده‌ای را داشت، اما «:type» دریافت گردید',
+    'zip_unique' => 'برای هر نوع شیء در فایل ZIP، مقدار ویژگی :attribute باید یکتا و بدون تکرار باشد.',
 
     // Custom validation lines
     'custom' => [
diff --git a/lang/fi/common.php b/lang/fi/common.php
index d6efe8e03..e05f40f44 100644
--- a/lang/fi/common.php
+++ b/lang/fi/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Luo',
     'update' => 'Päivitä',
     'edit' => 'Muokkaa',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Järjestä',
     'move' => 'Siirrä',
     'copy' => 'Kopioi',
diff --git a/lang/fi/editor.php b/lang/fi/editor.php
index 6b5683d38..8b72b4e5e 100644
--- a/lang/fi/editor.php
+++ b/lang/fi/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Yläindeksi',
     'subscript' => 'Alaindeksi',
     'text_color' => 'Tekstin väri',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Mukautettu väri',
     'remove_color' => 'Poista väri',
     'background_color' => 'Taustaväri',
diff --git a/lang/fi/entities.php b/lang/fi/entities.php
index 4865485ea..c64621f0d 100644
--- a/lang/fi/entities.php
+++ b/lang/fi/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Käyttöoikeudet',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Vakaa sisältö)',
     'pages_edit_switch_to_wysiwyg' => 'Vaihda WYSIWYG-editoriin',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Aseta muutosloki',
     'pages_edit_enter_changelog_desc' => 'Kirjoita lyhyt kuvaus tekemistäsi muutoksista',
     'pages_edit_enter_changelog' => 'Syötä muutosloki',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Lisää piirustus',
     'pages_md_show_preview' => 'Näytä esikatselu',
     'pages_md_sync_scroll' => 'Vieritä esikatselua koodin vierityksen mukaan',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Tallentamaton piirustus löytyi',
     'pages_drawing_unsaved_confirm' => 'Järjestelmä löysi tallentamattoman piirustuksen. Haluatko palauttaa piirustuksen ja jatkaa sen muokkaamista?',
     'pages_not_in_chapter' => 'Sivu ei kuulu mihinkään lukuun',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Kommentti',
     'comments' => 'Kommentit',
     'comment_add' => 'Lisää kommentti',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Jätä kommentti tähän',
-    'comment_count' => '{0} Ei kommentteja|{1} 1 kommentti|[2,*] :count kommenttia',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Tallenna kommentti',
     'comment_new' => 'Uusi kommentti',
     'comment_created' => 'kommentoi :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Kommentti poistettu',
     'comment_created_success' => 'Kommentti lisätty',
     'comment_updated_success' => 'Kommentti päivitetty',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Haluatko varmasti poistaa tämän kommentin?',
     'comment_in_reply_to' => 'Vastaus kommenttiin :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Tässä ovat sivulle jätetyt komentit. Kommentteja voi lisätä ja hallita, kun tarkastelet tallennettua sivua.',
 
     // Revision
diff --git a/lang/fi/errors.php b/lang/fi/errors.php
index d948b556b..9af7490d5 100644
--- a/lang/fi/errors.php
+++ b/lang/fi/errors.php
@@ -10,7 +10,7 @@ return [
 
     // Auth
     'error_user_exists_different_creds' => 'Sähköpostiosoite :email on jo käytössä toisessa käyttäjätunnuksessa.',
-    'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details',
+    'auth_pre_register_theme_prevention' => 'Käyttäjätiliä ei voitu rekisteröidä annetuille tiedoille',
     'email_already_confirmed' => 'Sähköposti on jo vahvistettu, yritä kirjautua sisään.',
     'email_confirmation_invalid' => 'Tämä vahvistuslinkki ei ole voimassa tai sitä on jo käytetty, yritä rekisteröityä uudelleen.',
     'email_confirmation_expired' => 'Vahvistuslinkki on vanhentunut, uusi vahvistussähköposti on lähetetty.',
@@ -38,7 +38,7 @@ Sovellus ei tunnista ulkoisen todennuspalvelun pyyntöä. Ongelman voi aiheuttaa
     'social_driver_not_found' => 'Sosiaalisen median tilin ajuria ei löytynyt',
     'social_driver_not_configured' => ':socialAccount-tilin asetuksia ei ole määritetty oikein.',
     'invite_token_expired' => 'Tämä kutsulinkki on vanhentunut. Voit sen sijaan yrittää palauttaa tilisi salasanan.',
-    'login_user_not_found' => 'A user for this action could not be found.',
+    'login_user_not_found' => 'Käyttäjää tälle toiminnolle ei löytynyt.',
 
     // System
     'path_not_writable' => 'Tiedostopolkuun :filePath ei voitu ladata tiedostoa. Tarkista polun kirjoitusoikeudet.',
@@ -79,7 +79,7 @@ Sovellus ei tunnista ulkoisen todennuspalvelun pyyntöä. Ongelman voi aiheuttaa
     // Users
     'users_cannot_delete_only_admin' => 'Ainoaa ylläpitäjää ei voi poistaa',
     'users_cannot_delete_guest' => 'Vieraskäyttäjää ei voi poistaa',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Käyttäjää ei voitu luoda kutsun lähettämisen jälkeen',
 
     // Roles
     'role_cannot_be_edited' => 'Tätä roolia ei voi muokata',
@@ -107,16 +107,16 @@ Sovellus ei tunnista ulkoisen todennuspalvelun pyyntöä. Ongelman voi aiheuttaa
     'back_soon' => 'Se palautetaan pian.',
 
     // Import
-    'import_zip_cant_read' => 'Could not read ZIP file.',
-    'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
-    'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
-    'import_validation_failed' => 'Import ZIP failed to validate with errors:',
-    'import_zip_failed_notification' => 'Failed to import ZIP file.',
-    'import_perms_books' => 'You are lacking the required permissions to create books.',
-    'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
-    'import_perms_pages' => 'You are lacking the required permissions to create pages.',
-    'import_perms_images' => 'You are lacking the required permissions to create images.',
-    'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
+    'import_zip_cant_read' => 'ZIP-tiedostoa ei voitu lukea.',
+    'import_zip_cant_decode_data' => 'ZIP-tiedoston data.json sisältöä ei löydy eikä sitä voitu purkaa.',
+    'import_zip_no_data' => 'ZIP-tiedostoilla ei ole odotettua kirjaa, lukua tai sivun sisältöä.',
+    'import_validation_failed' => 'Tuonti ZIP epäonnistui virheiden kanssa:',
+    'import_zip_failed_notification' => 'ZIP-tiedoston tuominen epäonnistui.',
+    'import_perms_books' => 'Sinulla ei ole tarvittavia oikeuksia luoda kirjoja.',
+    'import_perms_chapters' => 'Sinulla ei ole tarvittavia oikeuksia luoda kappaleita.',
+    'import_perms_pages' => 'Sinulla ei ole tarvittavia oikeuksia luoda sivuja.',
+    'import_perms_images' => 'Sinulla ei ole tarvittavia oikeuksia luoda kuvia.',
+    'import_perms_attachments' => 'Sinulla ei ole tarvittavaa lupaa luoda liitteitä.',
 
     // API errors
     'api_no_authorization_found' => 'Pyynnöstä ei löytynyt valtuutuskoodia',
diff --git a/lang/fi/settings.php b/lang/fi/settings.php
index d37ff79f4..d9e91d4fa 100644
--- a/lang/fi/settings.php
+++ b/lang/fi/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/fr/activities.php b/lang/fr/activities.php
index b65134a24..0e70917da 100644
--- a/lang/fr/activities.php
+++ b/lang/fr/activities.php
@@ -59,7 +59,7 @@ return [
     'favourite_remove_notification' => '":name" a été supprimé de vos favoris',
 
     // Watching
-    'watch_update_level_notification' => 'Suivre les préférences mises à jour avec succès',
+    'watch_update_level_notification' => 'Préférences de surveillance mises à jour avec succès',
 
     // Auth
     'auth_login' => 'connecté',
diff --git a/lang/fr/common.php b/lang/fr/common.php
index 0abff9954..8f8a31135 100644
--- a/lang/fr/common.php
+++ b/lang/fr/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Créer',
     'update' => 'Modifier',
     'edit' => 'Éditer',
+    'archive' => 'Archiver',
+    'unarchive' => 'Désarchiver',
     'sort' => 'Trier',
     'move' => 'Déplacer',
     'copy' => 'Copier',
diff --git a/lang/fr/editor.php b/lang/fr/editor.php
index fa620c520..4d4d8c0e8 100644
--- a/lang/fr/editor.php
+++ b/lang/fr/editor.php
@@ -35,7 +35,7 @@ return [
     'header_tiny' => 'En-tête minuscule',
     'paragraph' => 'Paragraphe',
     'blockquote' => 'Bloc de citation',
-    'inline_code' => 'Ligne de Code',
+    'inline_code' => 'Ligne de code',
     'callouts' => 'Légendes',
     'callout_information' => 'Information',
     'callout_success' => 'Succès',
@@ -47,7 +47,8 @@ return [
     'strikethrough' => 'Barré',
     'superscript' => 'Exposant',
     'subscript' => 'Indice',
-    'text_color' => 'Couleur Texte',
+    'text_color' => 'Couleur de texte',
+    'highlight_color' => 'Couleur de surlignage',
     'custom_color' => 'Couleur personnalisée',
     'remove_color' => 'Supprimer la couleur',
     'background_color' => 'Couleur d\'arrière-plan',
@@ -74,7 +75,7 @@ return [
     'insert_media_title' => 'Insérer/Modifier un média',
     'clear_formatting' => 'Effacer le formatage',
     'source_code' => 'Code source',
-    'source_code_title' => 'Code Source',
+    'source_code_title' => 'Code source',
     'fullscreen' => 'Plein écran',
     'image_options' => 'Options d\'image',
 
@@ -129,7 +130,7 @@ return [
     'caption' => 'Légende',
     'show_caption' => 'Afficher la légende',
     'constrain' => 'Conserver les proportions',
-    'cell_border_solid' => 'En continue',
+    'cell_border_solid' => 'Continu',
     'cell_border_dotted' => 'En pointillé',
     'cell_border_dashed' => 'En tirets',
     'cell_border_double' => 'En double trait',
diff --git a/lang/fr/entities.php b/lang/fr/entities.php
index 9992f7d78..ecf26442b 100644
--- a/lang/fr/entities.php
+++ b/lang/fr/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Ceci supprimera le fichier ZIP importé et ne pourra pas être annulé.',
     'import_errors' => 'Erreurs d\'importation',
     'import_errors_desc' => 'Les erreurs suivantes se sont produites lors de la tentative d\'importation :',
+    'breadcrumb_siblings_for_page' => 'Naviguer entre les pages voisines',
+    'breadcrumb_siblings_for_chapter' => 'Naviguer entre les chapitres voisins',
+    'breadcrumb_siblings_for_book' => 'Naviguer entre les livres voisins',
+    'breadcrumb_siblings_for_bookshelf' => 'Naviguer entre les étagères voisines',
 
     // Permissions and restrictions
     'permissions' => 'Autorisations',
@@ -239,7 +243,7 @@ return [
     'pages_edit_draft' => 'Modifier le brouillon',
     'pages_editing_draft' => 'Modification du brouillon',
     'pages_editing_page' => 'Modification de la page',
-    'pages_edit_draft_save_at' => 'Brouillon enregistré le ',
+    'pages_edit_draft_save_at' => 'Brouillon enregistré à ',
     'pages_edit_delete_draft' => 'Supprimer le brouillon',
     'pages_edit_delete_draft_confirm' => 'Êtes-vous sûr de vouloir supprimer vos modifications de page brouillon ? Toutes vos modifications, depuis la dernière sauvegarde complète, seront perdues et l\'éditeur sera mis à jour avec l\'état de sauvegarde de la dernière page non-brouillon.',
     'pages_edit_discard_draft' => 'Jeter le brouillon',
@@ -248,10 +252,10 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Contenu stable)',
     'pages_edit_switch_to_wysiwyg' => 'Basculer vers l\'éditeur WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Basculer vers le nouveau WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(En test Alpha)',
-    'pages_edit_set_changelog' => 'Remplir le journal des changements',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(En bêta-test)',
+    'pages_edit_set_changelog' => 'Journal des changements',
     'pages_edit_enter_changelog_desc' => 'Entrez une brève description des changements effectués',
-    'pages_edit_enter_changelog' => 'Ouvrir le journal des changements',
+    'pages_edit_enter_changelog' => 'Saisir les changements',
     'pages_editor_switch_title' => 'Changer d\'éditeur',
     'pages_editor_switch_are_you_sure' => 'Êtes-vous sûr de vouloir modifier l\'éditeur de cette page ?',
     'pages_editor_switch_consider_following' => 'Considérez ce qui suit lors du changement d\'éditeur :',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insérer un dessin',
     'pages_md_show_preview' => 'Prévisualisation',
     'pages_md_sync_scroll' => 'Défilement prévisualisation',
+    'pages_md_plain_editor' => 'Éditeur texte brut',
     'pages_drawing_unsaved' => 'Dessin non enregistré trouvé',
     'pages_drawing_unsaved_confirm' => 'Des données de dessin non enregistrées ont été trouvées à partir d\'une tentative de sauvegarde de dessin échouée. Voulez-vous restaurer et continuer à modifier ce dessin non sauvegardé ?',
     'pages_not_in_chapter' => 'La page n\'est pas dans un chapitre',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Commentaire',
     'comments' => 'Commentaires',
     'comment_add' => 'Ajouter un commentaire',
+    'comment_none' => 'Aucun commentaire à afficher',
     'comment_placeholder' => 'Entrez vos commentaires ici',
-    'comment_count' => '{0} Pas de commentaires|{1} Un commentaire|[2,*] :count commentaires',
+    'comment_thread_count' => ':count Fil de commentaires|:count Fils de commentaires',
+    'comment_archived_count' => ':count Archivé',
+    'comment_archived_threads' => 'Fils archivés',
     'comment_save' => 'Enregistrer le commentaire',
     'comment_new' => 'Nouveau commentaire',
     'comment_created' => 'commenté :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Commentaire supprimé',
     'comment_created_success' => 'Commentaire ajouté',
     'comment_updated_success' => 'Commentaire mis à jour',
+    'comment_archive_success' => 'Commentaire archivé',
+    'comment_unarchive_success' => 'Commentaire désarchiver',
+    'comment_view' => 'Voir le commentaire',
+    'comment_jump_to_thread' => 'Aller au fil',
     'comment_delete_confirm' => 'Êtes-vous sûr de vouloir supprimer ce commentaire ?',
     'comment_in_reply_to' => 'En réponse à :commentId',
+    'comment_reference' => 'Référence',
+    'comment_reference_outdated' => '(Obsolète)',
     'comment_editor_explain' => 'Voici les commentaires qui ont été laissés sur cette page. Les commentaires peuvent être ajoutés et gérés en visualisant la page enregistrée.',
 
     // Revision
diff --git a/lang/fr/settings.php b/lang/fr/settings.php
index fa125680b..e73d8a4b1 100644
--- a/lang/fr/settings.php
+++ b/lang/fr/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lituanien',
         'lv' => 'Letton',
         'nb' => 'Norvegien',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Néerlandais',
         'pl' => 'Polonais',
diff --git a/lang/he/common.php b/lang/he/common.php
index 4e4483ccf..2e387f67d 100644
--- a/lang/he/common.php
+++ b/lang/he/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'צור',
     'update' => 'עדכן',
     'edit' => 'ערוך',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'מיין',
     'move' => 'הזז',
     'copy' => 'העתק',
diff --git a/lang/he/editor.php b/lang/he/editor.php
index f119041b6..6390389b8 100644
--- a/lang/he/editor.php
+++ b/lang/he/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'כתב עילי',
     'subscript' => 'כתב תחתי',
     'text_color' => 'צבע טקסט',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'צבע מותאם',
     'remove_color' => 'הסר צבע',
     'background_color' => 'צבע רקע',
diff --git a/lang/he/entities.php b/lang/he/entities.php
index 60b6011cd..0cf2bb7fd 100644
--- a/lang/he/entities.php
+++ b/lang/he/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'תקלות יבוא',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'הרשאות',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'הגדר יומן שינויים',
     'pages_edit_enter_changelog_desc' => 'ציין תיאור קצר אודות השינויים שביצעת',
     'pages_edit_enter_changelog' => 'הכנס יומן שינויים',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'הכנס סרטוט',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'דף אינו חלק מפרק',
@@ -392,8 +397,11 @@ return [
     'comment' => 'תגובה',
     'comments' => 'תגובות',
     'comment_add' => 'הוסף תגובה',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'השאר תגובה כאן',
-    'comment_count' => '{0} אין תגובות|{1} 1 תגובה|[2,*] :count תגובות',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'שמור תגובה',
     'comment_new' => 'תגובה חדשה',
     'comment_created' => 'הוגב :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'התגובה נמחקה',
     'comment_created_success' => 'התגובה נוספה',
     'comment_updated_success' => 'התגובה עודכנה',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'האם ברצונך למחוק תגובה זו?',
     'comment_in_reply_to' => 'בתגובה ל :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/he/settings.php b/lang/he/settings.php
index 0e7efe048..5b405072f 100644
--- a/lang/he/settings.php
+++ b/lang/he/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/hr/common.php b/lang/hr/common.php
index 3c9ed610b..7cd779593 100644
--- a/lang/hr/common.php
+++ b/lang/hr/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Stvori',
     'update' => 'Ažuriraj',
     'edit' => 'Uredi',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Razvrstaj',
     'move' => 'Makni',
     'copy' => 'Kopiraj',
diff --git a/lang/hr/editor.php b/lang/hr/editor.php
index 274f41c84..4ac908318 100644
--- a/lang/hr/editor.php
+++ b/lang/hr/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Gornji indeks',
     'subscript' => 'Donji indeks',
     'text_color' => 'Boja teksta',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Prilagođena boja',
     'remove_color' => 'Ukloni boju',
     'background_color' => 'Boja pozadine',
diff --git a/lang/hr/entities.php b/lang/hr/entities.php
index 2dac21da2..99df50ea3 100644
--- a/lang/hr/entities.php
+++ b/lang/hr/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Dopuštenja',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stabilan Sadržaj)',
     'pages_edit_switch_to_wysiwyg' => 'Prebaci se na WYSIWYG uređivač',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Postavi dnevnik promjena',
     'pages_edit_enter_changelog_desc' => 'Ukratko opišite promjene koje ste napravili',
     'pages_edit_enter_changelog' => 'Unesi dnevnik promjena',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Umetni crtež',
     'pages_md_show_preview' => 'Prikaži pregled',
     'pages_md_sync_scroll' => 'Sinkroniziraj pomicanje pregleda',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Pronađen je Nespremljen Crtež',
     'pages_drawing_unsaved_confirm' => 'Pronađeni su nespremljeni podaci crteža iz prethodnog neuspjelog pokušaja spremanja crteža. Želite li obnoviti i nastaviti uređivati ovaj nespremljeni crtež?',
     'pages_not_in_chapter' => 'Stranica nije u poglavlju',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Komentar',
     'comments' => 'Komentari',
     'comment_add' => 'Dodaj komentar',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Komentar ostavi ovdje',
-    'comment_count' => '{0} Nema komentara|{1} 1 Komentar|[2,*] :count Komentara',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Spremi komentar',
     'comment_new' => 'Novi komentar',
     'comment_created' => 'komentirano :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Izbrisani komentar',
     'comment_created_success' => 'Dodani komentar',
     'comment_updated_success' => 'Ažurirani komentar',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Jeste li sigurni da želite izbrisati ovaj komentar?',
     'comment_in_reply_to' => 'Odgovor na :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Evo komentara koji su ostavljeni na ovoj stranici. Komentari se mogu dodavati i upravljati prilikom pregleda spremljene stranice.',
 
     // Revision
diff --git a/lang/hr/settings.php b/lang/hr/settings.php
index 1b9ec4896..5f0007426 100644
--- a/lang/hr/settings.php
+++ b/lang/hr/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/hu/common.php b/lang/hu/common.php
index 29a87e19e..e65e68909 100644
--- a/lang/hu/common.php
+++ b/lang/hu/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Létrehozás',
     'update' => 'Frissítés',
     'edit' => 'Szerkesztés',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Rendezés',
     'move' => 'Áthelyezés',
     'copy' => 'Másolás',
diff --git a/lang/hu/editor.php b/lang/hu/editor.php
index d5c6baed6..705bc6d54 100644
--- a/lang/hu/editor.php
+++ b/lang/hu/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Felső index',
     'subscript' => 'Alsó index',
     'text_color' => 'Szöveg szín',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Egyéni szín',
     'remove_color' => 'Szín eltávolítása',
     'background_color' => 'Háttérszín',
diff --git a/lang/hu/entities.php b/lang/hu/entities.php
index d073d2b74..570120dfa 100644
--- a/lang/hu/entities.php
+++ b/lang/hu/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Jogosultságok',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stabil tartalom)',
     'pages_edit_switch_to_wysiwyg' => 'Váltás a WYSIWYG szerkesztőre',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Változásnapló beállítása',
     'pages_edit_enter_changelog_desc' => 'A végrehajtott módosítások rövid leírása',
     'pages_edit_enter_changelog' => 'Változásnapló megadása',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Rajz beillesztése',
     'pages_md_show_preview' => 'Előnézet megjelenítése',
     'pages_md_sync_scroll' => 'Előnézet pozíció szinkronizálása',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Nem mentett rajz található',
     'pages_drawing_unsaved_confirm' => 'A rendszer nem mentett rajzadatokat talált egy korábbi sikertelen rajzmentési kísérletből. Szeretné visszaállítani és folytatni a nem mentett rajz szerkesztését?',
     'pages_not_in_chapter' => 'Az oldal nincs fejezetben',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Megjegyzés',
     'comments' => 'Megjegyzések',
     'comment_add' => 'Megjegyzés hozzáadása',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Megjegyzés írása',
-    'comment_count' => '{0} Nincs megjegyzés|{1} 1 megjegyzés|[2,*] :count megjegyzés',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Megjegyzés mentése',
     'comment_new' => 'Új megjegyzés',
     'comment_created' => 'megjegyzést fűzött hozzá :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Megjegyzés törölve',
     'comment_created_success' => 'Megjegyzés hozzáadva',
     'comment_updated_success' => 'Megjegyzés frissítve',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Biztosan törölhető ez a megjegyzés?',
     'comment_in_reply_to' => 'Válasz erre: :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Itt vannak az ezen az oldalon lévő megjegyzések. Megjegyzések hozzáadhatók és kezelhetők a mentett oldal megtekintésekor.',
 
     // Revision
diff --git a/lang/hu/settings.php b/lang/hu/settings.php
index b4e2e6207..813fee7b0 100644
--- a/lang/hu/settings.php
+++ b/lang/hu/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/id/activities.php b/lang/id/activities.php
index 380236439..6e1583a94 100644
--- a/lang/id/activities.php
+++ b/lang/id/activities.php
@@ -72,7 +72,7 @@ return [
     'mfa_remove_method_notification' => 'Metode multi-faktor sukses dihapus',
 
     // Settings
-    'settings_update' => 'updated settings',
+    'settings_update' => 'memperbarui setelan',
     'settings_update_notification' => 'Pengaturan berhasil diperbarui',
     'maintenance_action_run' => 'menjalankan tindakan pemeliharaan',
 
@@ -85,12 +85,12 @@ return [
     'webhook_delete_notification' => 'Webhook berhasil dihapus',
 
     // Imports
-    'import_create' => 'created import',
-    'import_create_notification' => 'Import successfully uploaded',
-    'import_run' => 'updated import',
-    'import_run_notification' => 'Content successfully imported',
-    'import_delete' => 'deleted import',
-    'import_delete_notification' => 'Import successfully deleted',
+    'import_create' => 'telat membuat impor',
+    'import_create_notification' => 'Impor berhasil diunggah',
+    'import_run' => 'telah memperbarui impor',
+    'import_run_notification' => 'Konten berhasil diimpor',
+    'import_delete' => 'telah menghapus impor',
+    'import_delete_notification' => 'Impor berhasil dihapus',
 
     // Users
     'user_create' => 'pengguna yang dibuat',
@@ -109,31 +109,31 @@ return [
     'api_token_delete_notification' => 'token API berhasil dihapus ',
 
     // Roles
-    'role_create' => 'created role',
+    'role_create' => 'telah membuat peran',
     'role_create_notification' => 'Peran berhasil dibuat',
-    'role_update' => 'updated role',
+    'role_update' => 'telah memperbarui peran',
     'role_update_notification' => 'Peran berhasil diperbarui',
-    'role_delete' => 'deleted role',
+    'role_delete' => 'telah menghapus peran',
     'role_delete_notification' => 'Peran berhasil dihapus',
 
     // Recycle Bin
-    'recycle_bin_empty' => 'emptied recycle bin',
-    'recycle_bin_restore' => 'restored from recycle bin',
-    'recycle_bin_destroy' => 'removed from recycle bin',
+    'recycle_bin_empty' => 'telah mengosongkan tempat sampah',
+    'recycle_bin_restore' => 'telah mengembalikan dari tempat sampah',
+    'recycle_bin_destroy' => 'telah menghapus dari tempat sampah',
 
     // Comments
     'commented_on'                => 'berkomentar pada',
-    'comment_create'              => 'added comment',
-    'comment_update'              => 'updated comment',
-    'comment_delete'              => 'deleted comment',
+    'comment_create'              => 'telah menambah komentar',
+    'comment_update'              => 'telah memperbarui komentar',
+    'comment_delete'              => 'telah menghapus komentar',
 
     // Sort Rules
-    'sort_rule_create' => 'created sort rule',
-    'sort_rule_create_notification' => 'Sort rule successfully created',
-    'sort_rule_update' => 'updated sort rule',
-    'sort_rule_update_notification' => 'Sort rule successfully updated',
-    'sort_rule_delete' => 'deleted sort rule',
-    'sort_rule_delete_notification' => 'Sort rule successfully deleted',
+    'sort_rule_create' => 'telah membuat aturan penyortiran',
+    'sort_rule_create_notification' => 'Aturan penyortiran berhasil dibuat',
+    'sort_rule_update' => 'telah mengubah aturan penyortiran',
+    'sort_rule_update_notification' => 'Aturan penyortiran berhasil diubah',
+    'sort_rule_delete' => 'telah menghapus aturan penyortiran',
+    'sort_rule_delete_notification' => 'Aturan penyortiran berhasil dihapus',
 
     // Other
     'permissions_update'          => 'izin diperbarui',
diff --git a/lang/id/auth.php b/lang/id/auth.php
index 552742bc6..6af60ca80 100644
--- a/lang/id/auth.php
+++ b/lang/id/auth.php
@@ -86,32 +86,32 @@ return [
     'mfa_setup_configured' => 'Sudah dikonfigurasi',
     'mfa_setup_reconfigure' => 'Konfigurasi ulang',
     'mfa_setup_remove_confirmation' => 'Apakah Anda yakin ingin menghapus metode autentikasi multi-faktor ini?',
-    'mfa_setup_action' => 'Setup',
-    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_setup_action' => 'Atur',
+    'mfa_backup_codes_usage_limit_warning' => 'Anda memiliki kurang dari 5 kode cadangan yang tersisa. Harap buat dan simpan set baru sebelum Anda kehabisan kode untuk mencegah akun Anda terkunci.',
     'mfa_option_totp_title' => 'Aplikasi Seluler',
-    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_totp_desc' => 'Untuk menggunakan autentikasi multi-faktor, Anda memerlukan aplikasi seluler yang mendukung TOTP seperti Google Authenticator, Authy, atau Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'Kode Cadangan',
-    'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.',
-    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
-    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
-    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
-    'mfa_gen_backup_codes_download' => 'Download Codes',
-    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
-    'mfa_gen_totp_title' => 'Mobile App Setup',
-    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
-    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
-    'mfa_gen_totp_verify_setup' => 'Verify Setup',
-    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
-    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
-    'mfa_verify_access' => 'Verify Access',
-    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
-    'mfa_verify_no_methods' => 'No Methods Configured',
-    'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
+    'mfa_option_backup_codes_desc' => 'Menghasilkan serangkaian kode cadangan sekali pakai yang akan Anda masukkan saat masuk untuk memverifikasi identitas Anda. Pastikan untuk menyimpannya di tempat yang aman.',
+    'mfa_gen_confirm_and_enable' => 'Konfirmasi dan Aktifkan',
+    'mfa_gen_backup_codes_title' => 'Pengaturan Kode Cadangan',
+    'mfa_gen_backup_codes_desc' => 'Simpan daftar kode di bawah ini di tempat yang aman. Saat mengakses sistem, Anda dapat menggunakan salah satu kode sebagai mekanisme autentikasi kedua.',
+    'mfa_gen_backup_codes_download' => 'Unduh Kode',
+    'mfa_gen_backup_codes_usage_warning' => 'Setiap kode hanya dapat digunakan satu kali',
+    'mfa_gen_totp_title' => 'Pengaturan Aplikasi Seluler',
+    'mfa_gen_totp_desc' => 'Untuk menggunakan autentikasi multi-faktor, Anda memerlukan aplikasi seluler yang mendukung TOTP seperti Google Authenticator, Authy, atau Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Pindai kode QR di bawah ini menggunakan aplikasi autentikasi pilihan Anda untuk memulai.',
+    'mfa_gen_totp_verify_setup' => 'Verifikasi Pengaturan',
+    'mfa_gen_totp_verify_setup_desc' => 'Verifikasi bahwa semuanya berfungsi dengan memasukkan kode yang dibuat dalam aplikasi autentikasi Anda pada kolom input di bawah ini:',
+    'mfa_gen_totp_provide_code_here' => 'Berikan kode yang dihasilkan aplikasi Anda di sini',
+    'mfa_verify_access' => 'Verifikasi Akses',
+    'mfa_verify_access_desc' => 'Akun pengguna Anda mengharuskan Anda mengonfirmasi identitas Anda melalui tingkat verifikasi tambahan sebelum Anda diberikan akses. Verifikasi menggunakan salah satu metode yang telah Anda konfigurasikan untuk melanjutkan.',
+    'mfa_verify_no_methods' => 'Tidak Ada Metode yang Dikonfigurasi',
+    'mfa_verify_no_methods_desc' => 'Tidak ada metode autentikasi multi-faktor yang ditemukan untuk akun Anda. Anda perlu menyiapkan setidaknya satu metode sebelum mendapatkan akses.',
     'mfa_verify_use_totp' => 'Verifikasi menggunakan aplikasi seluler',
     'mfa_verify_use_backup_codes' => 'Verifikasi menggunakan kode cadangan',
     'mfa_verify_backup_code' => 'Kode Cadangan',
-    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
-    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
-    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
-    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+    'mfa_verify_backup_code_desc' => 'Masukkan salah satu kode cadangan Anda yang tersisa di bawah ini:',
+    'mfa_verify_backup_code_enter_here' => 'Masukkan kode cadangan di sini',
+    'mfa_verify_totp_desc' => 'Masukkan kode yang dibuat menggunakan aplikasi seluler Anda di bawah ini:',
+    'mfa_setup_login_notification' => 'Metode multi-faktor dikonfigurasi. Silakan masuk lagi menggunakan metode yang dikonfigurasi.',
 ];
diff --git a/lang/id/common.php b/lang/id/common.php
index 06e9421c8..cee2d327f 100644
--- a/lang/id/common.php
+++ b/lang/id/common.php
@@ -6,7 +6,7 @@ return [
 
     // Buttons
     'cancel' => 'Batal',
-    'close' => 'Close',
+    'close' => 'Tutup',
     'confirm' => 'Konfirmasi',
     'back' => 'Kembali',
     'save' => 'Simpan',
@@ -20,16 +20,18 @@ return [
     'description' => 'Deskripsi',
     'role' => 'Peran',
     'cover_image' => 'Sampul gambar',
-    'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.',
+    'cover_image_description' => 'Gambar ini harus berukuran sekitar 440x250px walaupun nanti akan disesuaikan & terpotong secara fleksibel agar sesuai dengan tampilan pengguna, sehingga dimensi sebenarnya untuk tampilan akan berbeda.',
 
     // Actions
     'actions' => 'Tindakan',
     'view' => 'Lihat',
     'view_all' => 'Lihat Semua',
-    'new' => 'New',
+    'new' => 'Buat Baru',
     'create' => 'Buat',
     'update' => 'Perbarui',
     'edit' => 'Sunting',
+    'archive' => 'Buat Arsip',
+    'unarchive' => 'Batalkan Arsip',
     'sort' => 'Sortir',
     'move' => 'Pindahkan',
     'copy' => 'Salin',
@@ -41,18 +43,18 @@ return [
     'reset' => 'Atur ulang',
     'remove' => 'Hapus',
     'add' => 'Tambah',
-    'configure' => 'Configure',
-    'manage' => 'Manage',
+    'configure' => 'Atur',
+    'manage' => 'Kelola',
     'fullscreen' => 'Layar Penuh',
     'favourite' => 'Favorit',
     'unfavourite' => 'Batal favorit',
     'next' => 'Selanjutnya',
     'previous' => 'Sebelumnya',
-    'filter_active' => 'Active Filter:',
-    'filter_clear' => 'Clear Filter',
-    'download' => 'Download',
-    'open_in_tab' => 'Open in Tab',
-    'open' => 'Open',
+    'filter_active' => 'Filter Aktif:',
+    'filter_clear' => 'Hapus Filter',
+    'download' => 'Unduh',
+    'open_in_tab' => 'Buka di tab baru',
+    'open' => 'Buka',
 
     // Sort Options
     'sort_options' => 'Opsi Sortir',
@@ -78,20 +80,20 @@ return [
     'default' => 'Bawaan',
     'breadcrumb' => 'Breadcrumb',
     'status' => 'Status',
-    'status_active' => 'Active',
-    'status_inactive' => 'Inactive',
-    'never' => 'Never',
-    'none' => 'None',
+    'status_active' => 'Aktif',
+    'status_inactive' => 'Tidak Aktif',
+    'never' => 'Jangan Pernah',
+    'none' => 'Tidak Satupun',
 
     // Header
-    'homepage' => 'Homepage',
+    'homepage' => 'Beranda',
     'header_menu_expand' => 'Perluas Menu Tajuk',
     'profile_menu' => 'Menu Profil',
     'view_profile' => 'Tampilkan Profil',
     'edit_profile' => 'Sunting Profil',
     'dark_mode' => 'Mode Gelap',
     'light_mode' => 'Mode Terang',
-    'global_search' => 'Global Search',
+    'global_search' => 'Pencarian Global',
 
     // Layout tabs
     'tab_info' => 'Informasi',
@@ -109,5 +111,5 @@ return [
     'terms_of_service' => 'Ketentuan Layanan',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Cari :appName',
 ];
diff --git a/lang/id/components.php b/lang/id/components.php
index 373d6ace5..45aa72fba 100644
--- a/lang/id/components.php
+++ b/lang/id/components.php
@@ -6,36 +6,36 @@ return [
 
     // Image Manager
     'image_select' => 'Pilih Gambar',
-    'image_list' => 'Image List',
-    'image_details' => 'Image Details',
-    'image_upload' => 'Upload Image',
-    'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.',
-    'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.',
+    'image_list' => 'Daftar Gambar',
+    'image_details' => 'Detail Gambar',
+    'image_upload' => 'Unggah Gambar',
+    'image_intro' => 'Di sini Anda dapat memilih dan mengelola gambar yang sebelumnya diunggah ke sistem.',
+    'image_intro_upload' => 'Unggah gambar baru dengan menyeret berkas gambar ke jendela ini, atau dengan menggunakan tombol "Unggah Gambar" di atas.',
     'image_all' => 'Semua',
     'image_all_title' => 'Lihat semua gambar',
     'image_book_title' => 'Lihat gambar yang diunggah ke buku ini',
     'image_page_title' => 'Lihat gambar yang diunggah ke halaman ini',
     'image_search_hint' => 'Cari berdasarkan nama gambar',
     'image_uploaded' => 'Diunggah :uploadedDate',
-    'image_uploaded_by' => 'Uploaded by :userName',
-    'image_uploaded_to' => 'Uploaded to :pageLink',
-    'image_updated' => 'Updated :updateDate',
+    'image_uploaded_by' => 'Diunggah oleh :userName',
+    'image_uploaded_to' => 'Diunggah ke :pageLink',
+    'image_updated' => 'Diperbarui :updateDate',
     'image_load_more' => 'Muat lebih banyak',
     'image_image_name' => 'Muat lebih banyak',
     'image_delete_used' => 'Gambar ini digunakan untuk halaman dibawah ini.',
     'image_delete_confirm_text' => 'Anda yakin ingin menghapus gambar ini?',
     'image_select_image' => 'Pilih gambar',
     'image_dropzone' => 'Lepaskan gambar atau klik di sini untuk mengunggah',
-    'image_dropzone_drop' => 'Drop images here to upload',
+    'image_dropzone_drop' => 'Letakkan gambar di sini untuk diunggah',
     'images_deleted' => 'Gambar Dihapus',
     'image_preview' => 'Pratinjau Gambar',
     'image_upload_success' => 'Gambar berhasil diunggah',
     'image_update_success' => 'Detail gambar berhasil diperbarui',
     'image_delete_success' => 'Gambar berhasil dihapus',
-    'image_replace' => 'Replace Image',
-    'image_replace_success' => 'Image file successfully updated',
-    'image_rebuild_thumbs' => 'Regenerate Size Variations',
-    'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!',
+    'image_replace' => 'Ganti Gambar',
+    'image_replace_success' => 'Berkas gambar berhasil diperbarui',
+    'image_rebuild_thumbs' => 'Buat Ulang Variasi Ukuran',
+    'image_rebuild_thumbs_success' => 'Variasi ukuran gambar berhasil dibuat ulang!',
 
     // Code Editor
     'code_editor' => 'Edit Kode',
diff --git a/lang/id/editor.php b/lang/id/editor.php
index 40fc4a5ab..049993689 100644
--- a/lang/id/editor.php
+++ b/lang/id/editor.php
@@ -13,7 +13,7 @@ return [
     'cancel' => 'Batal',
     'save' => 'Simpan',
     'close' => 'Tutup',
-    'apply' => 'Apply',
+    'apply' => 'Terapkan',
     'undo' => 'Undo',
     'redo' => 'Ulangi',
     'left' => 'Kiri',
@@ -29,69 +29,70 @@ return [
 
     // Toolbar
     'formats' => 'Format',
-    'header_large' => 'Large Header',
-    'header_medium' => 'Medium Header',
-    'header_small' => 'Small Header',
-    'header_tiny' => 'Tiny Header',
-    'paragraph' => 'Paragraph',
-    'blockquote' => 'Blockquote',
-    'inline_code' => 'Inline code',
-    'callouts' => 'Callouts',
-    'callout_information' => 'Information',
+    'header_large' => 'Header Besar',
+    'header_medium' => 'Header Sedang',
+    'header_small' => 'Header Kecil',
+    'header_tiny' => 'Header Sangat Kecil',
+    'paragraph' => 'Paragraf',
+    'blockquote' => 'Kutipan blok',
+    'inline_code' => 'Kode sebaris',
+    'callouts' => 'Anotasi',
+    'callout_information' => 'Informasi',
     'callout_success' => 'Sukses',
     'callout_warning' => 'Peringatan',
     'callout_danger' => 'Bahaya',
     'bold' => 'Berani',
     'italic' => 'Italic',
     'underline' => 'Garis Bawah',
-    'strikethrough' => 'Strikethrough',
-    'superscript' => 'Superscript',
+    'strikethrough' => 'Coret',
+    'superscript' => 'Superskrip',
     'subscript' => 'Berlangganan',
     'text_color' => 'Warna teks',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Warna khusus',
     'remove_color' => 'Hapus Warna',
     'background_color' => 'Warna latar',
     'align_left' => 'Rata Kiri',
-    'align_center' => 'Align center',
-    'align_right' => 'Align right',
-    'align_justify' => 'Justify',
-    'list_bullet' => 'Bullet list',
-    'list_numbered' => 'Numbered list',
-    'list_task' => 'Task list',
-    'indent_increase' => 'Increase indent',
-    'indent_decrease' => 'Decrease indent',
-    'table' => 'Table',
-    'insert_image' => 'Insert image',
-    'insert_image_title' => 'Insert/Edit Image',
-    'insert_link' => 'Insert/edit link',
-    'insert_link_title' => 'Insert/Edit Link',
-    'insert_horizontal_line' => 'Insert horizontal line',
-    'insert_code_block' => 'Insert code block',
-    'edit_code_block' => 'Edit code block',
-    'insert_drawing' => 'Insert/edit drawing',
-    'drawing_manager' => 'Drawing manager',
-    'insert_media' => 'Insert/edit media',
-    'insert_media_title' => 'Insert/Edit Media',
-    'clear_formatting' => 'Clear formatting',
-    'source_code' => 'Source code',
-    'source_code_title' => 'Source Code',
-    'fullscreen' => 'Fullscreen',
-    'image_options' => 'Image options',
+    'align_center' => 'Rata tengah',
+    'align_right' => 'Rata kanan',
+    'align_justify' => 'Rata kanan kiri',
+    'list_bullet' => 'Daftar poin',
+    'list_numbered' => 'Daftar bernomor',
+    'list_task' => 'Daftar tugas',
+    'indent_increase' => 'Tambah indentasi',
+    'indent_decrease' => 'Kurangi indentasi',
+    'table' => 'Tabel',
+    'insert_image' => 'Sisipkan gambar',
+    'insert_image_title' => 'Sisipkan/Ubah Gambar',
+    'insert_link' => 'Sisipkan/ubah tautan',
+    'insert_link_title' => 'Sisipkan/Ubah Tautan',
+    'insert_horizontal_line' => 'Sisipkan garis horizontal',
+    'insert_code_block' => 'Sisipkan blok kode',
+    'edit_code_block' => 'Ubah blok kode',
+    'insert_drawing' => 'Sisipkan/ubah gambaran',
+    'drawing_manager' => 'Manajer Gambaran',
+    'insert_media' => 'Sisipkan/ubah media',
+    'insert_media_title' => 'Sisipkan/Ubah Media',
+    'clear_formatting' => 'Bersihkan format',
+    'source_code' => 'Kode sumber',
+    'source_code_title' => 'Kode Sumber',
+    'fullscreen' => 'Layar penuh',
+    'image_options' => 'Opsi gambar',
 
     // Tables
-    'table_properties' => 'Table properties',
-    'table_properties_title' => 'Table Properties',
-    'delete_table' => 'Delete table',
-    'table_clear_formatting' => 'Clear table formatting',
-    'resize_to_contents' => 'Resize to contents',
-    'row_header' => 'Row header',
-    'insert_row_before' => 'Insert row before',
-    'insert_row_after' => 'Insert row after',
-    'delete_row' => 'Delete row',
-    'insert_column_before' => 'Insert column before',
-    'insert_column_after' => 'Insert column after',
-    'delete_column' => 'Delete column',
-    'table_cell' => 'Cell',
+    'table_properties' => 'Properti tabel',
+    'table_properties_title' => 'Properti Tabel',
+    'delete_table' => 'Hapus tabel',
+    'table_clear_formatting' => 'Bersihkan format tabel',
+    'resize_to_contents' => 'Sesuaikan dengan ukuran konten',
+    'row_header' => 'Judul baris',
+    'insert_row_before' => 'Sisipkan baris sebelum',
+    'insert_row_after' => 'Sisipkan baris setelah',
+    'delete_row' => 'Hapus baris',
+    'insert_column_before' => 'Sisipkan kolom sebelum',
+    'insert_column_after' => 'Sisipkan kolom sesudah',
+    'delete_column' => 'Hapus kolom',
+    'table_cell' => 'Sel',
     'table_row' => 'Row',
     'table_column' => 'Column',
     'cell_properties' => 'Cell properties',
diff --git a/lang/id/entities.php b/lang/id/entities.php
index 63b6fe358..2f9329925 100644
--- a/lang/id/entities.php
+++ b/lang/id/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Izin',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Atur Changelog',
     'pages_edit_enter_changelog_desc' => 'Masukkan deskripsi singkat tentang perubahan yang Anda buat',
     'pages_edit_enter_changelog' => 'Masuk ke Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Sisipkan Gambar',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Halaman tidak dalam satu bab',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Komentar',
     'comments' => 'Komentar',
     'comment_add' => 'Tambah Komentar',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Tinggalkan komentar disini',
-    'comment_count' => '{0} Tidak ada komentar |{1} 1 Komentar |[2,*] :count Komentar',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Simpan Komentar',
     'comment_new' => 'Komentar Baru',
     'comment_created' => 'dikomentari oleh :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Komentar telah dihapus',
     'comment_created_success' => 'Komentar telah di tambahkan',
     'comment_updated_success' => 'Komentar Telah diperbaharui',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Anda yakin ingin menghapus komentar ini?',
     'comment_in_reply_to' => 'Sebagai balasan untuk :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/id/errors.php b/lang/id/errors.php
index b4e2c6feb..6f7661738 100644
--- a/lang/id/errors.php
+++ b/lang/id/errors.php
@@ -10,7 +10,7 @@ return [
 
     // Auth
     'error_user_exists_different_creds' => 'Pengguna dengan email :email sudah ada tetapi dengan kredensial berbeda.',
-    'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details',
+    'auth_pre_register_theme_prevention' => 'Akun pengguna tidak dapat didaftarkan untuk rincian yang diberikan',
     'email_already_confirmed' => 'Email telah dikonfirmasi, Coba masuk.',
     'email_confirmation_invalid' => 'Token konfirmasi ini tidak valid atau telah digunakan, Silakan coba mendaftar lagi.',
     'email_confirmation_expired' => 'Token konfirmasi telah kedaluwarsa, Email konfirmasi baru telah dikirim.',
@@ -23,9 +23,9 @@ return [
     'saml_no_email_address' => 'Tidak dapat menemukan sebuah alamat email untuk pengguna ini, dalam data yang diberikan oleh sistem autentikasi eksternal',
     'saml_invalid_response_id' => 'Permintaan dari sistem otentikasi eksternal tidak dikenali oleh sebuah proses yang dimulai oleh aplikasi ini. Menavigasi kembali setelah masuk dapat menyebabkan masalah ini.',
     'saml_fail_authed' => 'Masuk menggunakan :system gagal, sistem tidak memberikan otorisasi yang berhasil',
-    'oidc_already_logged_in' => 'Already logged in',
-    'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
-    'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
+    'oidc_already_logged_in' => 'Sudah masuk',
+    'oidc_no_email_address' => 'Tidak dapat menemukan alamat email untuk pengguna ini dalam data yang diberikan oleh sistem autentikasi eksternal',
+    'oidc_fail_authed' => 'Masuk menggunakan :system gagal, sistem tidak memberikan otorisasi yang berhasil',
     'social_no_action_defined' => 'Tidak ada tindakan yang ditentukan',
     'social_login_bad_response' => "Kesalahan yang diterima selama masuk menggunakan :socialAccount : \n:error",
     'social_account_in_use' => 'Akun :socialAccount ini sudah digunakan, Coba masuk melalui opsi :socialAccount.',
@@ -37,37 +37,37 @@ return [
     'social_driver_not_found' => 'Pengemudi sosial tidak ditemukan',
     'social_driver_not_configured' => 'Pengaturan sosial :socialAccount Anda tidak dikonfigurasi dengan benar.',
     'invite_token_expired' => 'Tautan undangan ini telah kedaluwarsa. Sebagai gantinya, Anda dapat mencoba mengatur ulang kata sandi akun Anda.',
-    'login_user_not_found' => 'A user for this action could not be found.',
+    'login_user_not_found' => 'Pengguna untuk tindakan ini tidak dapat ditemukan.',
 
     // System
     'path_not_writable' => 'Jalur berkas :filePath tidak dapat diunggah. Pastikan berkas tersebut dapat ditulis ke server.',
     'cannot_get_image_from_url' => 'Tidak dapat mengambil gambar dari :url',
     'cannot_create_thumbs' => 'Server tidak dapat membuat thumbnail. Harap periksa apakah Anda telah memasang ekstensi GD PHP.',
     'server_upload_limit' => 'Server tidak mengizinkan unggahan dengan ukuran ini. Harap coba ukuran berkas yang lebih kecil.',
-    'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.',
+    'server_post_limit' => 'Server tidak dapat menerima jumlah data yang diberikan. Coba lagi dengan data yang lebih sedikit atau berkas yang lebih kecil.',
     'uploaded'  => 'Server tidak mengizinkan unggahan dengan ukuran ini. Harap coba ukuran berkas yang lebih kecil.',
 
     // Drawing & Images
     'image_upload_error' => 'Terjadi kesalahan saat mengunggah gambar',
     'image_upload_type_error' => 'Jenis gambar yang diunggah tidak valid',
-    'image_upload_replace_type' => 'Image file replacements must be of the same type',
-    'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.',
-    'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.',
-    'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.',
-    'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
+    'image_upload_replace_type' => 'Penggantian file gambar harus berjenis sama',
+    'image_upload_memory_limit' => 'Gagal menangani pengunggahan gambar dan/atau membuat thumbnail karena keterbatasan sumber daya sistem.',
+    'image_thumbnail_memory_limit' => 'Gagal membuat variasi ukuran gambar karena keterbatasan sumber daya sistem.',
+    'image_gallery_thumbnail_memory_limit' => 'Gagal membuat thumbnail galeri karena keterbatasan sumber daya sistem.',
+    'drawing_data_not_found' => 'Data gambar tidak dapat dimuat. Berkas gambar mungkin sudah tidak ada atau Anda mungkin tidak memiliki izin untuk mengaksesnya.',
 
     // Attachments
     'attachment_not_found' => 'Lampiran tidak ditemukan',
-    'attachment_upload_error' => 'An error occurred uploading the attachment file',
+    'attachment_upload_error' => 'Terjadi kesalahan saat mengunggah berkas',
 
     // Pages
     'page_draft_autosave_fail' => 'Gagal menyimpan draf. Pastikan Anda memiliki koneksi internet sebelum menyimpan halaman ini',
-    'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content',
+    'page_draft_delete_fail' => 'Gagal menghapus draf halaman dan mengambil konten tersimpan halaman saat ini',
     'page_custom_home_deletion' => 'Tidak dapat menghapus sebuah halaman saat diatur sebagai sebuah halaman beranda',
 
     // Entities
     'entity_not_found' => 'Entitas tidak ditemukan',
-    'bookshelf_not_found' => 'Shelf not found',
+    'bookshelf_not_found' => 'Rak tidak ditemukan',
     'book_not_found' => 'Buku tidak ditemukan',
     'page_not_found' => 'Halaman tidak ditemukan',
     'chapter_not_found' => 'Bab tidak ditemukan',
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Anda tidak dapat menghapus satu-satunya admin',
     'users_cannot_delete_guest' => 'Anda tidak dapat menghapus pengguna tamu',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Tidak dapat membuat pengguna karena email undangan gagal dikirim',
 
     // Roles
     'role_cannot_be_edited' => 'Peran ini tidak dapat disunting',
@@ -106,16 +106,16 @@ return [
     'back_soon' => 'Ini akan segera kembali.',
 
     // Import
-    'import_zip_cant_read' => 'Could not read ZIP file.',
-    'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
-    'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
-    'import_validation_failed' => 'Import ZIP failed to validate with errors:',
-    'import_zip_failed_notification' => 'Failed to import ZIP file.',
-    'import_perms_books' => 'You are lacking the required permissions to create books.',
-    'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
-    'import_perms_pages' => 'You are lacking the required permissions to create pages.',
-    'import_perms_images' => 'You are lacking the required permissions to create images.',
-    'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
+    'import_zip_cant_read' => 'Tidak dapat membaca berkas ZIP.',
+    'import_zip_cant_decode_data' => 'Tidak dapat menemukan dan mendekode konten ZIP data.json.',
+    'import_zip_no_data' => 'Data berkas ZIP tidak berisi konten buku, bab, atau halaman yang diharapkan.',
+    'import_validation_failed' => 'Impor ZIP gagal divalidasi dengan kesalahan:',
+    'import_zip_failed_notification' => 'Gagal mengimpor berkas ZIP.',
+    'import_perms_books' => 'Anda tidak memiliki izin yang diperlukan untuk membuat buku.',
+    'import_perms_chapters' => 'Anda tidak memiliki izin yang diperlukan untuk membuat bab.',
+    'import_perms_pages' => 'Anda tidak memiliki izin yang diperlukan untuk membuat halaman.',
+    'import_perms_images' => 'Anda tidak memiliki izin yang diperlukan untuk membuat gambar.',
+    'import_perms_attachments' => 'Anda tidak memiliki izin yang diperlukan untuk membuat lampiran.',
 
     // API errors
     'api_no_authorization_found' => 'Tidak ada token otorisasi yang ditemukan pada permintaan tersebut',
@@ -129,5 +129,5 @@ return [
     'maintenance_test_email_failure' => 'Kesalahan dilempar saat mengirim email uji:',
 
     // HTTP errors
-    'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
+    'http_ssr_url_no_match' => 'URL tidak cocok dengan host SSR yang diizinkan yang dikonfigurasi',
 ];
diff --git a/lang/id/notifications.php b/lang/id/notifications.php
index 1afd23f1d..b22af1f73 100644
--- a/lang/id/notifications.php
+++ b/lang/id/notifications.php
@@ -4,24 +4,24 @@
  */
 return [
 
-    'new_comment_subject' => 'New comment on page: :pageName',
-    'new_comment_intro' => 'A user has commented on a page in :appName:',
-    'new_page_subject' => 'New page: :pageName',
-    'new_page_intro' => 'A new page has been created in :appName:',
-    'updated_page_subject' => 'Updated page: :pageName',
-    'updated_page_intro' => 'A page has been updated in :appName:',
-    'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.',
+    'new_comment_subject' => 'Komentar baru di halaman: :pageName',
+    'new_comment_intro' => 'Seorang pengguna telah mengomentari halaman di :appName:',
+    'new_page_subject' => 'Halaman baru: :pageName',
+    'new_page_intro' => 'Halaman baru telah dibuat di :appName:',
+    'updated_page_subject' => 'Halaman yang diperbarui: :pageName',
+    'updated_page_intro' => 'Halaman telah diperbarui di :appName:',
+    'updated_page_debounce' => 'Untuk mencegah banyaknya pemberitahuan, untuk sementara Anda tidak akan dikirimi pemberitahuan untuk pengeditan lebih lanjut pada halaman ini oleh editor yang sama.',
 
-    'detail_page_name' => 'Page Name:',
-    'detail_page_path' => 'Page Path:',
-    'detail_commenter' => 'Commenter:',
-    'detail_comment' => 'Comment:',
-    'detail_created_by' => 'Created By:',
-    'detail_updated_by' => 'Updated By:',
+    'detail_page_name' => 'Nama Halaman:',
+    'detail_page_path' => 'Jalur Halaman:',
+    'detail_commenter' => 'Komentator:',
+    'detail_comment' => 'Komentar:',
+    'detail_created_by' => 'Dibuat Oleh:',
+    'detail_updated_by' => 'Diperbarui Oleh:',
 
-    'action_view_comment' => 'View Comment',
-    'action_view_page' => 'View Page',
+    'action_view_comment' => 'Lihat Komentar',
+    'action_view_page' => 'Lihat Halaman',
 
-    'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.',
-    'footer_reason_link' => 'your notification preferences',
+    'footer_reason' => 'Pemberitahuan ini dikirimkan kepada Anda karena :link mencakup jenis aktivitas untuk item ini.',
+    'footer_reason_link' => 'preferensi notifikasi Anda',
 ];
diff --git a/lang/id/preferences.php b/lang/id/preferences.php
index 2872f5f3c..a1bb346b8 100644
--- a/lang/id/preferences.php
+++ b/lang/id/preferences.php
@@ -5,16 +5,16 @@
  */
 
 return [
-    'my_account' => 'My Account',
+    'my_account' => 'Akun Saya',
 
-    'shortcuts' => 'Shortcuts',
+    'shortcuts' => 'Pintasan',
     'shortcuts_interface' => 'UI Shortcut Preferences',
     'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
     'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.',
     'shortcuts_toggle_label' => 'Keyboard shortcuts enabled',
-    'shortcuts_section_navigation' => 'Navigation',
+    'shortcuts_section_navigation' => 'Navigasi',
     'shortcuts_section_actions' => 'Common Actions',
-    'shortcuts_save' => 'Save Shortcuts',
+    'shortcuts_save' => 'Simpan Pintasan',
     'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
     'shortcuts_update_success' => 'Shortcut preferences have been updated!',
     'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
@@ -30,7 +30,7 @@ return [
     'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.',
 
     'auth' => 'Access & Security',
-    'auth_change_password' => 'Change Password',
+    'auth_change_password' => 'Ubah Kata Sandi',
     'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.',
     'auth_change_password_success' => 'Password has been updated!',
 
diff --git a/lang/id/settings.php b/lang/id/settings.php
index 53d1b576a..51c0613cb 100644
--- a/lang/id/settings.php
+++ b/lang/id/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/is/common.php b/lang/is/common.php
index 721d844ed..84651be17 100644
--- a/lang/is/common.php
+++ b/lang/is/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Búa til',
     'update' => 'Uppfæra',
     'edit' => 'Breyta',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Flokka',
     'move' => 'Færa',
     'copy' => 'Afrita',
diff --git a/lang/is/editor.php b/lang/is/editor.php
index 95b8c54e6..2c4a24616 100644
--- a/lang/is/editor.php
+++ b/lang/is/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Háletur',
     'subscript' => 'Lágletur',
     'text_color' => 'Litur texta',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Sérsniðinn litur',
     'remove_color' => 'Fjarlægja lit',
     'background_color' => 'Bakgrunnslitur',
diff --git a/lang/is/entities.php b/lang/is/entities.php
index 1e5b0d9bc..4308835c7 100644
--- a/lang/is/entities.php
+++ b/lang/is/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Þetta mun eyða innsendri ZIP skrá, þessa aðgerð er ekki hægt að afturkalla.',
     'import_errors' => 'Villur í innflutningi',
     'import_errors_desc' => 'Eftirfarandi villur komu upp við innflutning:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Réttindi',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Skipta yfir í WYSIWYG ritil',
     'pages_edit_switch_to_new_wysiwyg' => 'Skipta yfir í nýja WYSIWYG ritilinn',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(Í alfa prófun)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Stilla breytingarskrá',
     'pages_edit_enter_changelog_desc' => 'Skrifaðu stutta lýsingu á breytingunum sem þú gerðir',
     'pages_edit_enter_changelog' => 'Færa í breytingaskrá',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Setja inn teikningu',
     'pages_md_show_preview' => 'Sýna forskoðun',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Síðan tilheyrir engum kafla',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/is/settings.php b/lang/is/settings.php
index 08f922f73..3765db061 100644
--- a/lang/is/settings.php
+++ b/lang/is/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/it/common.php b/lang/it/common.php
index d45f93c5e..9eba738a4 100644
--- a/lang/it/common.php
+++ b/lang/it/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Crea',
     'update' => 'Aggiorna',
     'edit' => 'Modifica',
+    'archive' => 'Archivia',
+    'unarchive' => 'Ripristina',
     'sort' => 'Ordina',
     'move' => 'Sposta',
     'copy' => 'Copia',
diff --git a/lang/it/editor.php b/lang/it/editor.php
index 3fb517ffb..8e6147708 100644
--- a/lang/it/editor.php
+++ b/lang/it/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Apice',
     'subscript' => 'Pedice',
     'text_color' => 'Colore del testo',
+    'highlight_color' => 'Colore evidenziazione',
     'custom_color' => 'Colore personalizzato',
     'remove_color' => 'Rimuovi colore',
     'background_color' => 'Colore di sfondo',
diff --git a/lang/it/entities.php b/lang/it/entities.php
index 2762f1933..eb9868973 100644
--- a/lang/it/entities.php
+++ b/lang/it/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Questa operazione cancella il file ZIP di importazione caricato e non può essere annullata.',
     'import_errors' => 'Errori di importazione',
     'import_errors_desc' => 'Gli seguenti errori si sono verificati durante il tentativo di importazione:',
+    'breadcrumb_siblings_for_page' => 'Naviga tra le pagine correlate',
+    'breadcrumb_siblings_for_chapter' => 'Naviga tra i capitoli correlati',
+    'breadcrumb_siblings_for_book' => 'Naviga tra i libri correlati',
+    'breadcrumb_siblings_for_bookshelf' => 'Naviga tra le librerie correlate',
 
     // Permissions and restrictions
     'permissions' => 'Permessi',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Contenuto stabile)',
     'pages_edit_switch_to_wysiwyg' => 'Passa all\'editor WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Passa al nuovo WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In test alpha)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Test)',
     'pages_edit_set_changelog' => 'Imposta changelog',
     'pages_edit_enter_changelog_desc' => 'Inserisci una breve descrizione dei cambiamenti che hai apportato',
     'pages_edit_enter_changelog' => 'Inserisci changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Inserisci disegno',
     'pages_md_show_preview' => 'Visualizza anteprima',
     'pages_md_sync_scroll' => 'Sincronizza scorrimento anteprima',
+    'pages_md_plain_editor' => 'Editor di testo semplice',
     'pages_drawing_unsaved' => 'Trovato disegno non salvato',
     'pages_drawing_unsaved_confirm' => 'Sono stati trovati i dati di un disegno non salvato da un precedente tentativo di salvataggio di disegno non riuscito. Ripristinare e continuare a modificare questo disegno non salvato?',
     'pages_not_in_chapter' => 'La pagina non è in un capitolo',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Commento',
     'comments' => 'Commenti',
     'comment_add' => 'Aggiungi commento',
+    'comment_none' => 'Nessun commento da visualizzare',
     'comment_placeholder' => 'Scrivi un commento',
-    'comment_count' => '{0} Nessun commento|{1} 1 commento|[2,*] :count commenti',
+    'comment_thread_count' => ':count Commento Thread| :count Commenti Threads',
+    'comment_archived_count' => ':count Archiviato',
+    'comment_archived_threads' => 'Discussioni Archiviate',
     'comment_save' => 'Salva commento',
     'comment_new' => 'Nuovo commento',
     'comment_created' => 'ha commentato :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Commento eliminato',
     'comment_created_success' => 'Commento aggiunto',
     'comment_updated_success' => 'Commento aggiornato',
+    'comment_archive_success' => 'Commento archiviato',
+    'comment_unarchive_success' => 'Commento ripristinato',
+    'comment_view' => 'Visualizza commento',
+    'comment_jump_to_thread' => 'Vai al thread',
     'comment_delete_confirm' => 'Sei sicuro di voler eliminare questo commento?',
     'comment_in_reply_to' => 'In risposta a :commentId',
+    'comment_reference' => 'Riferimento',
+    'comment_reference_outdated' => '(Obsoleto)',
     'comment_editor_explain' => 'Ecco i commenti che sono stati lasciati in questa pagina. I commenti possono essere aggiunti e gestiti quando si visualizza la pagina salvata.',
 
     // Revision
diff --git a/lang/it/settings.php b/lang/it/settings.php
index 3b57db1b1..8bda7f8ff 100644
--- a/lang/it/settings.php
+++ b/lang/it/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lituano',
         'lv' => 'Lettone',
         'nb' => 'Norvegese (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Olandese',
         'pl' => 'Polacco',
diff --git a/lang/ja/common.php b/lang/ja/common.php
index 4f15bafbb..e888c43f6 100644
--- a/lang/ja/common.php
+++ b/lang/ja/common.php
@@ -30,6 +30,8 @@ return [
     'create' => '作成',
     'update' => '更新',
     'edit' => '編集',
+    'archive' => 'アーカイブ',
+    'unarchive' => 'アーカイブ解除',
     'sort' => '並び順',
     'move' => '移動',
     'copy' => 'コピー',
diff --git a/lang/ja/editor.php b/lang/ja/editor.php
index 6d4d8506e..40307dd52 100644
--- a/lang/ja/editor.php
+++ b/lang/ja/editor.php
@@ -47,7 +47,8 @@ return [
     'strikethrough' => '取消線',
     'superscript' => '上付き',
     'subscript' => '下付き',
-    'text_color' => 'テキストの色',
+    'text_color' => 'テキスト色',
+    'highlight_color' => 'テキスト背景色',
     'custom_color' => 'カスタムカラー',
     'remove_color' => '色設定を解除',
     'background_color' => '背景色',
diff --git a/lang/ja/entities.php b/lang/ja/entities.php
index afd776578..551a2e496 100644
--- a/lang/ja/entities.php
+++ b/lang/ja/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'アップロードされたインポートZIPファイルは削除され、元に戻すことはできません。',
     'import_errors' => 'インポートエラー',
     'import_errors_desc' => 'インポート中に次のエラーが発生しました：',
+    'breadcrumb_siblings_for_page' => '階層内のページ',
+    'breadcrumb_siblings_for_chapter' => '階層内のチャプタ',
+    'breadcrumb_siblings_for_book' => '階層内のブック',
+    'breadcrumb_siblings_for_bookshelf' => '階層内の棚',
 
     // Permissions and restrictions
     'permissions' => '権限',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(安定したコンテンツ)',
     'pages_edit_switch_to_wysiwyg' => 'WYSIWYGエディタに切り替え',
     'pages_edit_switch_to_new_wysiwyg' => '新しいWYSIWYGエディタに切り替える',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(アルファテスト版)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '（ベータテスト版）',
     'pages_edit_set_changelog' => '編集内容についての説明',
     'pages_edit_enter_changelog_desc' => 'どのような変更を行ったのかを記録してください',
     'pages_edit_enter_changelog' => '編集内容を入力',
@@ -261,13 +265,14 @@ return [
     'pages_save' => 'ページを保存',
     'pages_title' => 'ページタイトル',
     'pages_name' => 'ページ名',
-    'pages_md_editor' => 'エディター',
+    'pages_md_editor' => 'エディタ',
     'pages_md_preview' => 'プレビュー',
     'pages_md_insert_image' => '画像を挿入',
     'pages_md_insert_link' => 'エンティティへのリンクを挿入',
     'pages_md_insert_drawing' => '図を追加',
     'pages_md_show_preview' => 'プレビューを表示',
     'pages_md_sync_scroll' => 'プレビューとスクロールを同期',
+    'pages_md_plain_editor' => 'プレーンテキスト エディタ',
     'pages_drawing_unsaved' => '未保存の図が見つかりました',
     'pages_drawing_unsaved_confirm' => '以前に保存操作が失敗した、未保存の図が見つかりました。
 未保存の図面を復元して編集を続けますか？',
@@ -393,8 +398,11 @@ return [
     'comment' => 'コメント',
     'comments' => 'コメント',
     'comment_add' => 'コメント追加',
+    'comment_none' => '表示するコメントがありません',
     'comment_placeholder' => 'コメントを記入してください',
-    'comment_count' => '{0} コメントはありません|[1,*] :count 件のコメント',
+    'comment_thread_count' => ':count 個のコメントスレッド|:count 個のコメントスレッド',
+    'comment_archived_count' => ':count 個のアーカイブ',
+    'comment_archived_threads' => 'アーカイブされたスレッド',
     'comment_save' => 'コメントを保存',
     'comment_new' => '新規コメント作成',
     'comment_created' => 'コメントを作成しました :createDiff',
@@ -403,8 +411,14 @@ return [
     'comment_deleted_success' => 'コメントを削除しました',
     'comment_created_success' => 'コメントを追加しました',
     'comment_updated_success' => 'コメントを更新しました',
+    'comment_archive_success' => 'コメントをアーカイブしました',
+    'comment_unarchive_success' => 'コメントのアーカイブを解除しました',
+    'comment_view' => 'コメントを表示',
+    'comment_jump_to_thread' => 'スレッドにジャンプ',
     'comment_delete_confirm' => '本当にこのコメントを削除しますか?',
     'comment_in_reply_to' => ':commentIdへ返信',
+    'comment_reference' => '参照箇所',
+    'comment_reference_outdated' => '（以前の記述）',
     'comment_editor_explain' => 'ここにはページに付けられたコメントを表示します。 コメントの追加と管理は保存されたページの表示時に行うことができます。',
 
     // Revision
diff --git a/lang/ja/settings.php b/lang/ja/settings.php
index 1a0e64cb4..db14f3ccc 100644
--- a/lang/ja/settings.php
+++ b/lang/ja/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/ka/common.php b/lang/ka/common.php
index b05169bb2..06a9e855c 100644
--- a/lang/ka/common.php
+++ b/lang/ka/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Create',
     'update' => 'Update',
     'edit' => 'Edit',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sort',
     'move' => 'Move',
     'copy' => 'Copy',
diff --git a/lang/ka/editor.php b/lang/ka/editor.php
index 752c6f3f7..0d250e9a7 100644
--- a/lang/ka/editor.php
+++ b/lang/ka/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superscript',
     'subscript' => 'Subscript',
     'text_color' => 'Text color',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Custom color',
     'remove_color' => 'Remove color',
     'background_color' => 'Background color',
diff --git a/lang/ka/entities.php b/lang/ka/entities.php
index a74785eaa..74c50be3b 100644
--- a/lang/ka/entities.php
+++ b/lang/ka/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Set Changelog',
     'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
     'pages_edit_enter_changelog' => 'Enter Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insert Drawing',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Page is not in a chapter',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/ka/settings.php b/lang/ka/settings.php
index 82a4ade5d..81c2c0a93 100644
--- a/lang/ka/settings.php
+++ b/lang/ka/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/ko/common.php b/lang/ko/common.php
index 00caf4f54..ff35f0971 100644
--- a/lang/ko/common.php
+++ b/lang/ko/common.php
@@ -30,6 +30,8 @@ return [
     'create' => '만들기',
     'update' => '바꾸기',
     'edit' => '수정',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => '정렬',
     'move' => '이동',
     'copy' => '복사',
diff --git a/lang/ko/editor.php b/lang/ko/editor.php
index ac4d8d53d..b5c4ae807 100644
--- a/lang/ko/editor.php
+++ b/lang/ko/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => '윗첨자',
     'subscript' => '아랫첨자',
     'text_color' => '글자 색상',
+    'highlight_color' => 'Highlight color',
     'custom_color' => '사용자 지정 색상',
     'remove_color' => '색상 제거',
     'background_color' => '배경 색상',
diff --git a/lang/ko/entities.php b/lang/ko/entities.php
index c13f49371..b5624f221 100644
--- a/lang/ko/entities.php
+++ b/lang/ko/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => '업로드된 ZIP 파일이 삭제되며, 실행 취소할 수 없습니다.',
     'import_errors' => '가져오기 오류',
     'import_errors_desc' => '가져오기 중 에러가 발생했습니다.',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => '권한',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'WYSIWYG 편집기로 전환',
     'pages_edit_switch_to_new_wysiwyg' => '새 위지윅 편집기로 변경',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '알파 테스트 중',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => '수정본 설명',
     'pages_edit_enter_changelog_desc' => '수정본 설명',
     'pages_edit_enter_changelog' => '변경 로그 입력란',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => '드로잉 추가',
     'pages_md_show_preview' => '미리보기 표시',
     'pages_md_sync_scroll' => '미리보기 스크롤 동기화',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => '저장되지 않은 드로잉 발견',
     'pages_drawing_unsaved_confirm' => '이전에 실패한 드로잉 저장 시도에서 저장되지 않은 드로잉 데이터가 발견되었습니다. 이 저장되지 않은 드로잉을 복원하고 계속 편집하시겠습니까?',
     'pages_not_in_chapter' => '챕터에 있는 문서가 아닙니다.',
@@ -392,8 +397,11 @@ return [
     'comment' => '댓글',
     'comments' => '댓글',
     'comment_add' => '댓글 쓰기',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => '이곳에 댓글을 쓰세요...',
-    'comment_count' => '{0} 댓글 없음|{1} 댓글 1개|[2,*] 댓글 :count개',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => '등록',
     'comment_new' => '새로운 댓글',
     'comment_created' => '댓글 등록함 :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => '댓글 지움',
     'comment_created_success' => '댓글 등록함',
     'comment_updated_success' => '댓글 수정함',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => '이 댓글을 지울 건가요?',
     'comment_in_reply_to' => ':commentId(을)를 향한 답글',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => '이 페이지에 남겨진 댓글은 다음과 같습니다. 저장된 페이지를 볼 때 댓글을 추가하고 관리할 수 있습니다.',
 
     // Revision
diff --git a/lang/ko/settings.php b/lang/ko/settings.php
index b78333385..797c12043 100644
--- a/lang/ko/settings.php
+++ b/lang/ko/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/ku/common.php b/lang/ku/common.php
index d1481e0ec..8d799d028 100644
--- a/lang/ku/common.php
+++ b/lang/ku/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'دروستکردن',
     'update' => 'نوێکردنەوە',
     'edit' => 'دەسکاریکردن',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'ڕیزکردن',
     'move' => 'جوڵاندن',
     'copy' => 'لەبەرگرتنەوە',
diff --git a/lang/ku/editor.php b/lang/ku/editor.php
index 752c6f3f7..0d250e9a7 100644
--- a/lang/ku/editor.php
+++ b/lang/ku/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superscript',
     'subscript' => 'Subscript',
     'text_color' => 'Text color',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Custom color',
     'remove_color' => 'Remove color',
     'background_color' => 'Background color',
diff --git a/lang/ku/entities.php b/lang/ku/entities.php
index a74785eaa..74c50be3b 100644
--- a/lang/ku/entities.php
+++ b/lang/ku/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Set Changelog',
     'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
     'pages_edit_enter_changelog' => 'Enter Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insert Drawing',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Page is not in a chapter',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/ku/settings.php b/lang/ku/settings.php
index 82a4ade5d..81c2c0a93 100644
--- a/lang/ku/settings.php
+++ b/lang/ku/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/lt/common.php b/lang/lt/common.php
index e375c835c..bbafc347c 100644
--- a/lang/lt/common.php
+++ b/lang/lt/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Sukurti',
     'update' => 'Atnaujinti',
     'edit' => 'Redaguoti',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Rūšiuoti',
     'move' => 'Perkelti',
     'copy' => 'Kopijuoti',
diff --git a/lang/lt/editor.php b/lang/lt/editor.php
index 752c6f3f7..0d250e9a7 100644
--- a/lang/lt/editor.php
+++ b/lang/lt/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superscript',
     'subscript' => 'Subscript',
     'text_color' => 'Text color',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Custom color',
     'remove_color' => 'Remove color',
     'background_color' => 'Background color',
diff --git a/lang/lt/entities.php b/lang/lt/entities.php
index 1687195c5..562456781 100644
--- a/lang/lt/entities.php
+++ b/lang/lt/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Leidimai',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Nustatyti keitimo žurnalą',
     'pages_edit_enter_changelog_desc' => 'Įveskite trumpus, jūsų atliktus, pokyčių aprašymus',
     'pages_edit_enter_changelog' => 'Įeiti į keitimo žurnalą',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Įterpti piešinį',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Puslapio nėra skyriuje',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Komentaras',
     'comments' => 'Komentarai',
     'comment_add' => 'Pridėti komentarą',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Palikite komentarą čia',
-    'comment_count' => '{0} Nėra komentarų|{1} 1 komentaras|[2,*] :count komentarai',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Išsaugoti komentarą',
     'comment_new' => 'Naujas komentaras',
     'comment_created' => 'Pakomentuota :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Komentaras ištrintas',
     'comment_created_success' => 'Komentaras pridėtas',
     'comment_updated_success' => 'Komentaras atnaujintas',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Esate tikri, kad norite ištrinti šį komentarą?',
     'comment_in_reply_to' => 'Atsakydamas į :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/lt/settings.php b/lang/lt/settings.php
index e3bd927dc..563038bf2 100644
--- a/lang/lt/settings.php
+++ b/lang/lt/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/lv/activities.php b/lang/lv/activities.php
index 3f6e38c13..38d9ae217 100644
--- a/lang/lv/activities.php
+++ b/lang/lv/activities.php
@@ -85,12 +85,12 @@ return [
     'webhook_delete_notification' => 'Webhook veiksmīgi izdzēsts',
 
     // Imports
-    'import_create' => 'created import',
-    'import_create_notification' => 'Import successfully uploaded',
-    'import_run' => 'updated import',
-    'import_run_notification' => 'Content successfully imported',
-    'import_delete' => 'deleted import',
-    'import_delete_notification' => 'Import successfully deleted',
+    'import_create' => 'izveidoja importu',
+    'import_create_notification' => 'Imports veiksmīgi augšupielādēts',
+    'import_run' => 'atjaunoja importu',
+    'import_run_notification' => 'Saturs veiksmīgi importēts',
+    'import_delete' => 'izdzēsa importu',
+    'import_delete_notification' => 'Imports veiksmīgi dzēsts',
 
     // Users
     'user_create' => 'izveidoja lietotāju',
@@ -128,12 +128,12 @@ return [
     'comment_delete'              => 'dzēsa komentāru',
 
     // Sort Rules
-    'sort_rule_create' => 'created sort rule',
-    'sort_rule_create_notification' => 'Sort rule successfully created',
-    'sort_rule_update' => 'updated sort rule',
-    'sort_rule_update_notification' => 'Sort rule successfully updated',
-    'sort_rule_delete' => 'deleted sort rule',
-    'sort_rule_delete_notification' => 'Sort rule successfully deleted',
+    'sort_rule_create' => 'izveidoja kārtošanas nosacījumu',
+    'sort_rule_create_notification' => 'Kārtošanas nosacījums veiksmīgi izveidots',
+    'sort_rule_update' => 'atjaunoja kārtošanas nosacījumu',
+    'sort_rule_update_notification' => 'Kārtošanas nosacījums veiksmīgi atjaunots',
+    'sort_rule_delete' => 'izdzēsa kārtošanas nosacījumu',
+    'sort_rule_delete_notification' => 'Kārtošanas nosacījums veiksmīgi dzēsts',
 
     // Other
     'permissions_update'          => 'atjaunoja atļaujas',
diff --git a/lang/lv/common.php b/lang/lv/common.php
index 584b24cb2..4917ad3d7 100644
--- a/lang/lv/common.php
+++ b/lang/lv/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Izveidot',
     'update' => 'Atjaunināt',
     'edit' => 'Rediģēt',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Kārtot',
     'move' => 'Pārvietot',
     'copy' => 'Kopēt',
@@ -109,5 +111,5 @@ return [
     'terms_of_service' => 'Pakalpojuma noteikumi',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Meklēt :appName',
 ];
diff --git a/lang/lv/editor.php b/lang/lv/editor.php
index 05863fd30..8b2907ddd 100644
--- a/lang/lv/editor.php
+++ b/lang/lv/editor.php
@@ -13,7 +13,7 @@ return [
     'cancel' => 'Atcelt',
     'save' => 'Saglabāt',
     'close' => 'Aizvērt',
-    'apply' => 'Apply',
+    'apply' => 'Pielietot',
     'undo' => 'Atsaukt',
     'redo' => 'Atcelt atsaukšanu',
     'left' => 'Pa kreisi',
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Augšraksts',
     'subscript' => 'Apakšraksts',
     'text_color' => 'Teksta krāsa',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Pielāgot krāsu',
     'remove_color' => 'Noņemt krāsu',
     'background_color' => 'Fona krāsa',
@@ -148,7 +149,7 @@ return [
     'url' => 'URL',
     'text_to_display' => 'Attēlojamais teksts',
     'title' => 'Nosaukums',
-    'browse_links' => 'Browse links',
+    'browse_links' => 'Pārlūkot saites',
     'open_link' => 'Atvērt saiti',
     'open_link_in' => 'Atvērt saiti...',
     'open_link_current' => 'Šis logs',
@@ -165,8 +166,8 @@ return [
     'about' => 'Par redaktoru',
     'about_title' => 'Par WYSIWYG redaktoru',
     'editor_license' => 'Redaktora licence un autortiesības',
-    'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
-    'editor_lexical_license_link' => 'Full license details can be found here.',
+    'editor_lexical_license' => 'Šis redaktors ir izveidots, izmantojot :tinyLink, kas ir publicēts ar MIT licenci.',
+    'editor_lexical_license_link' => 'Pilnu licences informāciju var atrast šeit.',
     'editor_tiny_license' => 'Šis redaktors ir izveidots, izmantojot :tinyLink, kas ir publicēts ar MIT licenci.',
     'editor_tiny_license_link' => 'TinyMCE autortiesības un licences detaļas var atrast šeit.',
     'save_continue' => 'Saglabāt lapu un turpināt',
diff --git a/lang/lv/entities.php b/lang/lv/entities.php
index e9aad99e5..5f921048e 100644
--- a/lang/lv/entities.php
+++ b/lang/lv/entities.php
@@ -39,30 +39,34 @@ return [
     'export_pdf' => 'PDF fails',
     'export_text' => 'Vienkāršs teksta fails',
     'export_md' => 'Markdown fails',
-    'export_zip' => 'Portable ZIP',
+    'export_zip' => 'Pārceļams ZIP arhīvs',
     'default_template' => 'Noklusētā lapas sagatave',
-    'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
+    'default_template_explain' => 'Norādīt lapas sagatavi, kas tiks izmantota kā noklusētais saturs visām jaunājām lapām šajā grāmatā. Ņemiet vērā, ka tā tiks izmantota tikai tad, ja lapas veidotājam ir skatīšanas tiesības izvēlētajai sagatavei.',
     'default_template_select' => 'Izvēlēt sagataves lapu',
-    'import' => 'Import',
-    'import_validate' => 'Validate Import',
-    'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
-    'import_zip_select' => 'Select ZIP file to upload',
-    'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
-    'import_pending' => 'Pending Imports',
-    'import_pending_none' => 'No imports have been started.',
-    'import_continue' => 'Continue Import',
-    'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
-    'import_details' => 'Import Details',
-    'import_run' => 'Run Import',
-    'import_size' => ':size Import ZIP Size',
-    'import_uploaded_at' => 'Uploaded :relativeTime',
-    'import_uploaded_by' => 'Uploaded by',
-    'import_location' => 'Import Location',
-    'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
-    'import_delete_confirm' => 'Are you sure you want to delete this import?',
-    'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
-    'import_errors' => 'Import Errors',
-    'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'import' => 'Importēt',
+    'import_validate' => 'Pārbaudīt importu',
+    'import_desc' => 'Importēt grāmatas, nodaļas un lapas izmantojot pārceļamu ZIP arhīvu no šīs vai citas sistēmas instances. Izvēlietites ZIP failu, lai turpinātu. Kad fails ir augšupielādēts un pārbaudīts, jūs varēsiet veikt importa uzstādījumus un to apstiprināt nākamajā skatā.',
+    'import_zip_select' => 'Izvēlieties ZIP failu, ko augšupielādēt',
+    'import_zip_validation_errors' => 'Pārbaudot ZIP failu atrastas šādas kļūdas:',
+    'import_pending' => 'Gaidošie importi',
+    'import_pending_none' => 'Neviens imports nav uzsākts.',
+    'import_continue' => 'Turpināt importu',
+    'import_continue_desc' => 'Pārlūkot saturu, kas tiktu importēts no augšupielādētā ZIP faila. Kad esat gatavs, palaidiet importu, lai pievienotu tā saturu šai sistēmai. Augšupielādētais ZIP fails tiks automātiski izvākts pēc veiksmīga importa.',
+    'import_details' => 'Importa detaļas',
+    'import_run' => 'Palaist importu',
+    'import_size' => ':size importa ZIP izmērs',
+    'import_uploaded_at' => 'Augšupielādes laiks :relativeTime',
+    'import_uploaded_by' => 'Augšupielādēja',
+    'import_location' => 'Importa vieta',
+    'import_location_desc' => 'Izvēlieties mērķa vietu jūsu importētajam saturam. Jums būs nepieciešamas attiecīgās piekļuves tiesības, lai izveidotu saturu izvēlētajā vietā.',
+    'import_delete_confirm' => 'Vai tiešām vēlaties dzēst šo importu?',
+    'import_delete_desc' => 'Šis izdzēsīs augšupielādēto importa ZIP failu, un šo darbību nevarēs atcelt.',
+    'import_errors' => 'Importa kļūdas',
+    'import_errors_desc' => 'Importa mēģinājumā atgadījās šīs kļūdas:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Atļaujas',
@@ -247,8 +251,8 @@ return [
     'pages_edit_switch_to_markdown_clean' => '(Iztīrītais saturs)',
     'pages_edit_switch_to_markdown_stable' => '(Stabilais saturs)',
     'pages_edit_switch_to_wysiwyg' => 'Pārslēgties uz WYSIWYG redaktoru',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Pārslēgties uz jauno WYSIWYG redaktoru',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Pievienot izmaiņu aprakstu',
     'pages_edit_enter_changelog_desc' => 'Ievadi nelielu aprakstu par vaiktajām izmaiņām',
     'pages_edit_enter_changelog' => 'Izmaiņu apraksts',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Ievietot zīmējumu',
     'pages_md_show_preview' => 'Rādīt priekšskatu',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Atrasts nesaglabāts attēls',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Lapa nav nodaļā',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Komentārs',
     'comments' => 'Komentāri',
     'comment_add' => 'Pievienot komentāru',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Pievieno komentāru',
-    'comment_count' => '{0} Nav komentāru |{1} 1 Komentārs|[2,*] :count Komentāri',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Saglabāt komentāru',
     'comment_new' => 'Jauns komentārs',
     'comment_created' => 'komentējis :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Komentārs ir dzēsts',
     'comment_created_success' => 'Komentārs ir pievienots',
     'comment_updated_success' => 'Komentārs ir atjaunināts',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Vai esat pārliecināts, ka vēlaties dzēst šo komentāru?',
     'comment_in_reply_to' => 'Atbildēt uz :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
@@ -452,9 +466,9 @@ return [
     'watch_desc_comments_page' => 'Paziņot par lapu izmaiņām un jauniem komentāriem.',
     'watch_change_default' => 'Izmainīt noklusētos paziņojumu uzstādījumus',
     'watch_detail_ignore' => 'Ignorēt paziņojumus',
-    'watch_detail_new' => 'Watching for new pages',
-    'watch_detail_updates' => 'Watching new pages and updates',
-    'watch_detail_comments' => 'Watching new pages, updates & comments',
+    'watch_detail_new' => 'Vērot jaunas lapas',
+    'watch_detail_updates' => 'Vērot jaunas lapas un atjauninājumus',
+    'watch_detail_comments' => 'Vērot jaunas lapas, atjauninājumus un komentārus',
     'watch_detail_parent_book' => 'Watching via parent book',
     'watch_detail_parent_book_ignore' => 'Ignoring via parent book',
     'watch_detail_parent_chapter' => 'Watching via parent chapter',
diff --git a/lang/lv/errors.php b/lang/lv/errors.php
index 40d755e61..28cc0d892 100644
--- a/lang/lv/errors.php
+++ b/lang/lv/errors.php
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Jūs nevarat dzēst vienīgo administratoru',
     'users_cannot_delete_guest' => 'Jūs nevarat dzēst lietotāju "viesis"',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Neizdevās izveidot lietotāju, jo neizdevās nosūtīt ielūguma epastu',
 
     // Roles
     'role_cannot_be_edited' => 'Šo lomu nevar rediģēt',
@@ -106,16 +106,16 @@ return [
     'back_soon' => 'Drīz būs atkal pieejams.',
 
     // Import
-    'import_zip_cant_read' => 'Could not read ZIP file.',
-    'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
-    'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
-    'import_validation_failed' => 'Import ZIP failed to validate with errors:',
-    'import_zip_failed_notification' => 'Failed to import ZIP file.',
-    'import_perms_books' => 'You are lacking the required permissions to create books.',
-    'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
-    'import_perms_pages' => 'You are lacking the required permissions to create pages.',
-    'import_perms_images' => 'You are lacking the required permissions to create images.',
-    'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
+    'import_zip_cant_read' => 'Nevarēja nolasīt ZIP failu.',
+    'import_zip_cant_decode_data' => 'Nevarēja atrast un nolasīt data.json saturu ZIP failā.',
+    'import_zip_no_data' => 'ZIP faila datos nav atrasts grāmatu, nodaļu vai lapu saturs.',
+    'import_validation_failed' => 'ZIP faila imports ir neveiksmīgs ar šādām kļūdām:',
+    'import_zip_failed_notification' => 'ZIP faila imports ir neveiksmīgs.',
+    'import_perms_books' => 'Jums nav nepieciešamo tiesību izveidot grāmatas.',
+    'import_perms_chapters' => 'Jums nav nepieciešamo tiesību izveidot nodaļas.',
+    'import_perms_pages' => 'Jums nav nepieciešamo tiesību izveidot lapas.',
+    'import_perms_images' => 'Jums nav nepieciešamo tiesību izviedot attēlus.',
+    'import_perms_attachments' => 'Jums nav nepieciešamo tiesību izveidot pielikumus.',
 
     // API errors
     'api_no_authorization_found' => 'Pieprasījumā nav atrasts autorizācijas žetons',
diff --git a/lang/lv/preferences.php b/lang/lv/preferences.php
index 18def9f5c..b91cc2e08 100644
--- a/lang/lv/preferences.php
+++ b/lang/lv/preferences.php
@@ -9,25 +9,25 @@ return [
 
     'shortcuts' => 'Saīsnes',
     'shortcuts_interface' => 'Saskarnes īsceļu iestatījumi',
-    'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
-    'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.',
+    'shortcuts_toggle_desc' => 'Šeit jūs varat ieslēgt vai izslēgt sistēmas saskarnes klaviatūras īsceļus, kas tiek izmantoti navigācijai un darbībām.',
+    'shortcuts_customize_desc' => 'Jūs varat pielāgot katru no zemāk esošajiem īsceļiem. Vienkārši nospiediet nepieciešamo pogu kombināciju pēc tam, kad izvēlēts īsceļa ievadlauks.',
     'shortcuts_toggle_label' => 'Klaviatūras saīsnes ieslēgtas',
     'shortcuts_section_navigation' => 'Navigācija',
     'shortcuts_section_actions' => 'Biežākās darbības',
     'shortcuts_save' => 'Saglabāt saīsnes',
-    'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
+    'shortcuts_overlay_desc' => 'Piezīme: kad īsceļi ir ieslēgti, ir pieejams palīdzības lodziņš, kas parādās, nospiežot "?". Tas attēlos pieejamos īsceļus tajā brīdī pieejamajām darbībām.',
     'shortcuts_update_success' => 'Saīsņu uzstādījumi ir saglabāt!',
-    'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
+    'shortcuts_overview_desc' => 'Pārvaldīt klaviatūras īsceļus, ko var izmantot sistēmas saskarnes navigācijai.',
 
     'notifications' => 'Paziņojumu iestatījumi',
-    'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.',
+    'notifications_desc' => 'Pārvaldiet epasta paziņojumus, ko saņemsiet, kad sistēmā tiek veiktas noteiktas darbības.',
     'notifications_opt_own_page_changes' => 'Paziņot par izmaiņām manās lapās',
     'notifications_opt_own_page_comments' => 'Paziņot par komentāriem manās lapās',
     'notifications_opt_comment_replies' => 'Paziņot par atbildēm uz maniem komentāriem',
     'notifications_save' => 'Saglabāt iestatījumus',
     'notifications_update_success' => 'Paziņojumu iestatījumi ir atjaunoti!',
     'notifications_watched' => 'Vērotie un ignorētie vienumi',
-    'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.',
+    'notifications_watched_desc' => 'Zemāk ir vienumi, kam piemēroti īpaši vērošanas nosacījumi. Lai atjaunintātu savus uzstādījums šiem, apskatiet vienumu un tad sameklējiet vērošanas uzstādījumus sānu kolonnā.',
 
     'auth' => 'Piekļuve un drošība',
     'auth_change_password' => 'Mainīt paroli',
@@ -35,12 +35,12 @@ return [
     'auth_change_password_success' => 'Parole ir nomainīta!',
 
     'profile' => 'Profila informācija',
-    'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.',
+    'profile_desc' => 'Pārvaldiet sava konta detaļas, kas jūs attēlo citiem lietotājiem, kā arī detaļas, kas tiek izmantotas saziņai un sistēmas pielāgošanai.',
     'profile_view_public' => 'Skatīt publisko profilu',
-    'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.',
+    'profile_name_desc' => 'Uzstādiet savu vārdu, kas tiks parādīts citiem lietotājiem sistēmā pie jūsu darbībām un jums piederošā satura.',
     'profile_email_desc' => 'Šis epasts tiks izmantots paziņojumiem un sistēmas piekļuvei, atkarībā no sistēmā uzstādītās autentifikācijas metodes.',
     'profile_email_no_permission' => 'Diemžēl jums nav tiesību mainīt savu epasta adresi. Ja vēlaties to mainīt, jums jāsazinās ar administratoru, lai tas nomaina šo adresi.',
-    'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.',
+    'profile_avatar_desc' => 'Izvēlieties attēlu, kas tiks izmantots, lai jūs attēlotu citiem sistēmas lietotājiem. Ideālā gadījumā šim attēlam jābūt kvadrātaveida, apmēram 256px platumā un augstumā.',
     'profile_admin_options' => 'Administratora iestatījumi',
     'profile_admin_options_desc' => 'Papildus administratora iestatījumus, kā piemēram, lomu piešķiršanu, var atrast jūsu lietotāja kontā ejot uz "Uzstādījumu > Lietotāji".',
 
diff --git a/lang/lv/settings.php b/lang/lv/settings.php
index fa0e21acc..9901f7811 100644
--- a/lang/lv/settings.php
+++ b/lang/lv/settings.php
@@ -75,34 +75,34 @@ return [
     'reg_confirm_restrict_domain_placeholder' => 'Nav ierobežojumu',
 
     // Sorting Settings
-    'sorting' => 'Sorting',
-    'sorting_book_default' => 'Default Book Sort',
-    'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.',
-    'sorting_rules' => 'Sort Rules',
-    'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.',
-    'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books',
-    'sort_rule_create' => 'Create Sort Rule',
-    'sort_rule_edit' => 'Edit Sort Rule',
-    'sort_rule_delete' => 'Delete Sort Rule',
-    'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.',
-    'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?',
-    'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?',
-    'sort_rule_details' => 'Sort Rule Details',
-    'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.',
-    'sort_rule_operations' => 'Sort Operations',
-    'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.',
-    'sort_rule_available_operations' => 'Available Operations',
-    'sort_rule_available_operations_empty' => 'No operations remaining',
-    'sort_rule_configured_operations' => 'Configured Operations',
-    'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list',
-    'sort_rule_op_asc' => '(Asc)',
-    'sort_rule_op_desc' => '(Desc)',
-    'sort_rule_op_name' => 'Name - Alphabetical',
-    'sort_rule_op_name_numeric' => 'Name - Numeric',
-    'sort_rule_op_created_date' => 'Created Date',
-    'sort_rule_op_updated_date' => 'Updated Date',
-    'sort_rule_op_chapters_first' => 'Chapters First',
-    'sort_rule_op_chapters_last' => 'Chapters Last',
+    'sorting' => 'Kārtošana',
+    'sorting_book_default' => 'Noklusētā grāmatu kārtošana',
+    'sorting_book_default_desc' => 'Izvēlieties noklusēto kārtošanas nosacījumu, ko pielietot jaunām grāmatām. Šis neskars jau esošas grāmatas, un to var izmainīt grāmatas iestatījumos.',
+    'sorting_rules' => 'Kārtošanas noteikumi',
+    'sorting_rules_desc' => 'Šīs ir iepriekš noteiktas kārtošanas darbības, ko var pielietot saturam šajā sistēmā.',
+    'sort_rule_assigned_to_x_books' => 'Pielietots :count grāmatai|Pielietots :count grāmatām',
+    'sort_rule_create' => 'Izveidot kārtošanas nosacījumu',
+    'sort_rule_edit' => 'Rediģēt kārtošanas nosacījumu',
+    'sort_rule_delete' => 'Dzēst kārtošanas nosacījumu',
+    'sort_rule_delete_desc' => 'Izņemt šo kārtošanas nosacījumu no sistēmas. Grāmatām tiks atjaunota manuāla kārtošana.',
+    'sort_rule_delete_warn_books' => 'Šis kārtošanas nosacījums pašlaik tiek izmantots :count grāmatām. Vai tiešām vēlaties to dzēst?',
+    'sort_rule_delete_warn_default' => 'Šis kārtošanas nosacījums pašlaik ir norādīts kā noklusētais. Vai tiešām vēlaties to dzēst?',
+    'sort_rule_details' => 'Kārtošanas nosacījuma detaļas',
+    'sort_rule_details_desc' => 'Uzstādiet nosaukumu šim kārtošanas nosacījumam, kas parādīsies sarakstā, kad lietotājs izvēlēsies kārtošanas veidu.',
+    'sort_rule_operations' => 'Kārtošanas darbības',
+    'sort_rule_operations_desc' => 'Konfigurējiet kārtošanas darbības pārvelkot tās no pieejamo darb\'biu saraksta. Lietošanas procesā darbības tiks piemērotas saraksta kārtībā no augšas uz apakšu. Jekbādas izmaiņas šeit tiks piemērotas grāmatās, kur šis piemērots, pie saglabāšanas.',
+    'sort_rule_available_operations' => 'Pieejamās darbības',
+    'sort_rule_available_operations_empty' => 'Nav atlikušas darbības',
+    'sort_rule_configured_operations' => 'Uzstādītās darbības',
+    'sort_rule_configured_operations_empty' => 'Ievelciet/pievienojiet darbības no "Pieejamo darbību" saraksta',
+    'sort_rule_op_asc' => '(Pieaug.)',
+    'sort_rule_op_desc' => '(Dilst.)',
+    'sort_rule_op_name' => 'Nosaukums - alfabētiski',
+    'sort_rule_op_name_numeric' => 'Nosaukums - numuriski',
+    'sort_rule_op_created_date' => 'Izveidošanas datums',
+    'sort_rule_op_updated_date' => 'Atjaunināšanas datums',
+    'sort_rule_op_chapters_first' => 'Nodaļas pirmās',
+    'sort_rule_op_chapters_last' => 'Nodaļas pēdējās',
 
     // Maintenance settings
     'maint' => 'Apkope',
@@ -192,7 +192,7 @@ return [
     'role_access_api' => 'Piekļūt sistēmas API',
     'role_manage_settings' => 'Pārvaldīt iestatījumus',
     'role_export_content' => 'Eksportēt saturu',
-    'role_import_content' => 'Import content',
+    'role_import_content' => 'Importēt saturu',
     'role_editor_change' => 'Mainīt lapu redaktoru',
     'role_notifications' => 'Saņemt un pārvaldīt paziņojumus',
     'role_asset' => 'Resursa piekļuves tiesības',
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/lv/validation.php b/lang/lv/validation.php
index 5b714af86..dd318119a 100644
--- a/lang/lv/validation.php
+++ b/lang/lv/validation.php
@@ -105,10 +105,10 @@ return [
     'url'                  => ':attribute formāts nav derīgs.',
     'uploaded'             => 'Fails netika ielādēts. Serveris nevar pieņemt šāda izmēra failus.',
 
-    'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
-    'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
-    'zip_model_expected' => 'Data object expected but ":type" found.',
-    'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
+    'zip_file' => ':attribute ir jāatsaucas uz failu ZIP arhīvā.',
+    'zip_file_mime' => ':attribute ir jāatsaucas uz failu ar tipu :validTypes, bet atrasts :foundType.',
+    'zip_model_expected' => 'Sagaidīts datu objekts, bet atrasts ":type".',
+    'zip_unique' => ':attribute jābūt unikālam šim objekta tipam ZIP arhīvā.',
 
     // Custom validation lines
     'custom' => [
diff --git a/lang/nb/common.php b/lang/nb/common.php
index 1214e81c5..38104ba03 100644
--- a/lang/nb/common.php
+++ b/lang/nb/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Opprett',
     'update' => 'Oppdater',
     'edit' => 'Rediger',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sortér',
     'move' => 'Flytt',
     'copy' => 'Kopier',
diff --git a/lang/nb/editor.php b/lang/nb/editor.php
index ddb266626..77b01f17b 100644
--- a/lang/nb/editor.php
+++ b/lang/nb/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Hevet skrift',
     'subscript' => 'Senket skrift',
     'text_color' => 'Tekstfarge',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Egenvalgt farge',
     'remove_color' => 'Fjern farge',
     'background_color' => 'Bakgrunnsfarge',
diff --git a/lang/nb/entities.php b/lang/nb/entities.php
index 85faf1e85..50f3847d8 100644
--- a/lang/nb/entities.php
+++ b/lang/nb/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Dette vil slette den opplastede importen av ZIP-filen og kan ikke angres.',
     'import_errors' => 'Import feil',
     'import_errors_desc' => 'Feil oppstod under importforsøket:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Tilganger',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Urørt innhold)',
     'pages_edit_switch_to_wysiwyg' => 'Bytt til WYSIWYG tekstredigering',
     'pages_edit_switch_to_new_wysiwyg' => 'Bytt til ny WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(I Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Angi endringslogg',
     'pages_edit_enter_changelog_desc' => 'Gi en kort beskrivelse av endringene dine',
     'pages_edit_enter_changelog' => 'Se endringslogg',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Sett inn tegning',
     'pages_md_show_preview' => 'Forhåndsvisning',
     'pages_md_sync_scroll' => 'Synkroniser forhåndsvisningsrulle',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Ulagret tegning funnet',
     'pages_drawing_unsaved_confirm' => 'Ulagret tegningsdata ble funnet fra en tidligere mislykket lagring. Vil du gjenopprette og fortsette å redigere denne ulagrede tegningen?',
     'pages_not_in_chapter' => 'Siden tilhører ingen kapittel',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Kommentar',
     'comments' => 'Kommentarer',
     'comment_add' => 'Skriv kommentar',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Skriv en kommentar her',
-    'comment_count' => '{0} Ingen kommentarer|{1} 1 kommentar|[2,*] :count kommentarer',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Publiser kommentar',
     'comment_new' => 'Ny kommentar',
     'comment_created' => 'kommenterte :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Kommentar fjernet',
     'comment_created_success' => 'Kommentar skrevet',
     'comment_updated_success' => 'Kommentar endret',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Er du sikker på at du vil fjerne kommentaren?',
     'comment_in_reply_to' => 'Som svar til :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Her er kommentarene som er på denne siden. Kommentarer kan legges til og administreres når du ser på den lagrede siden.',
 
     // Revision
diff --git a/lang/nb/settings.php b/lang/nb/settings.php
index a719d41cc..68bab6f30 100644
--- a/lang/nb/settings.php
+++ b/lang/nb/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/ne/activities.php b/lang/ne/activities.php
new file mode 100644
index 000000000..4b4edf7be
--- /dev/null
+++ b/lang/ne/activities.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'पाना सिर्जना गरियो',
+    'page_create_notification'    => 'पाना सफलतापूर्वक सिर्जना गरियो',
+    'page_update'                 => 'पाना अद्यावधिक गरियो',
+    'page_update_notification'    => 'पाना सफलतापूर्वक अद्यावधिक गरियो',
+    'page_delete'                 => 'पाना हटाइयो',
+    'page_delete_notification'    => 'पाना सफलतापूर्वक हटाइयो',
+    'page_restore'                => 'पाना पुनर्स्थापित गरियो',
+    'page_restore_notification'   => 'पाना सफलतापूर्वक पुनर्स्थापित गरियो',
+    'page_move'                   => 'पाना सारियो',
+    'page_move_notification'      => 'पाना सफलतापूर्वक सारियो',
+
+    // Chapters
+    'chapter_create'              => 'अध्याय सिर्जना गरियो',
+    'chapter_create_notification' => 'अध्याय सफलतापूर्वक सिर्जना गरियो',
+    'chapter_update'              => 'अध्याय अद्यावधिक गरियो',
+    'chapter_update_notification' => 'अध्याय सफलतापूर्वक अद्यावधिक गरियो',
+    'chapter_delete'              => 'अध्याय हटाइयो',
+    'chapter_delete_notification' => 'अध्याय सफलतापूर्वक हटाइयो',
+    'chapter_move'                => 'अध्याय सारियो',
+    'chapter_move_notification' => 'अध्याय सफलतापूर्वक सारियो',
+
+    // Books
+    'book_create'                 => 'पुस्तक सिर्जना गरियो',
+    'book_create_notification'    => 'पुस्तक सफलतापूर्वक सिर्जना गरियो',
+    'book_create_from_chapter'              => 'अध्यायलाई पुस्तकमा परिणत गरियो',
+    'book_create_from_chapter_notification' => 'अध्यायलाई पुस्तकमा सफलतापूर्वक परिणत गरियो',
+    'book_update'                 => 'पुस्तक अद्यावधिक गरियो',
+    'book_update_notification'    => 'पुस्तक सफलतापूर्वक अद्यावधिक गरियो',
+    'book_delete'                 => 'पुस्तक हटाइयो',
+    'book_delete_notification'    => 'पुस्तक सफलतापूर्वक हटाइयो',
+    'book_sort'                   => 'पुस्तक क्रमबद्ध गरियो',
+    'book_sort_notification'      => 'पुस्तक सफलतापूर्वक क्रमबद्ध गरियो',
+
+    // Bookshelves
+    'bookshelf_create'            => 'दराज बनाइयो',
+    'bookshelf_create_notification'    => 'दराज सफलतापूर्वक बनाइयो',
+    'bookshelf_create_from_book'    => 'पुस्तकलाई दराजमा परिणत गरियो',
+    'bookshelf_create_from_book_notification'    => 'पुस्तकलाई दराजमा सफलतापूर्वक परिणत गरियो',
+    'bookshelf_update'                 => 'दराज अद्यावधिक गरियो',
+    'bookshelf_update_notification'    => 'दराज सफलतापूर्वक अद्यावधिक गरियो',
+    'bookshelf_delete'                 => 'दराज हटाइयो',
+    'bookshelf_delete_notification'    => 'दराज सफलतापूर्वक हटाइयो',
+
+    // Revisions
+    'revision_restore' => 'संशोधन पुनर्स्थापित गरियो',
+    'revision_delete' => 'संशोधन हटाइयो',
+    'revision_delete_notification' => 'संशोधन सफलतापूर्वक हटाइयो',
+
+    // Favourites
+    'favourite_add_notification' => '":name" तपाईंको मनपर्नेमा थपिएको छ',
+    'favourite_remove_notification' => '":name" तपाईंको मनपर्नेबाट हटाइएको छ',
+
+    // Watching
+    'watch_update_level_notification' => 'हेर्ने अभिरुचि सफलतापूर्वक अद्यावधिक गरियो',
+
+    // Auth
+    'auth_login' => 'लग इन भयो',
+    'auth_register' => 'नयाँ प्रयोगकर्ता रूपमा दर्ता गरियो',
+    'auth_password_reset_request' => 'प्रयोगकर्ताको पासवर्ड रिसेटको अनुरोध गरियो',
+    'auth_password_reset_update' => 'प्रयोगकर्ता पासवर्ड रिसेट गर्नुहोस्',
+    'mfa_setup_method' => 'MFA विधि सेटअप गरियो',
+    'mfa_setup_method_notification' => 'बहु-कारक प्रमाणीकरण विधि सफलतापूर्वक सेटअप गरियो',
+    'mfa_remove_method' => 'MFA हटाइयो',
+    'mfa_remove_method_notification' => 'बहु-कारक प्रमाणीकरण विधि सफलतापूर्वक हटाइयो',
+
+    // Settings
+    'settings_update' => 'सेटिङहरू अद्यावधिक गरियो',
+    'settings_update_notification' => 'सेटिङहरू सफलतापूर्वक अद्यावधिक गरियो',
+    'maintenance_action_run' => 'मर्मत कार्य सञ्चालन गरियो',
+
+    // Webhooks
+    'webhook_create' => 'वेबहुक सिर्जना गरियो',
+    'webhook_create_notification' => 'वेबहुक सफलतापूर्वक सिर्जना गरियो',
+    'webhook_update' => 'वेबहुकअद्यावधिक गरियो',
+    'webhook_update_notification' => 'वेबहुक सफलतापूर्वक अद्यावधिक गरियो',
+    'webhook_delete' => 'वेबहुक हटाइयो',
+    'webhook_delete_notification' => 'वेबहुक सफलतापूर्वक हटाइयो',
+
+    // Imports
+    'import_create' => 'आयात सिर्जना गरियो',
+    'import_create_notification' => 'आयात सफलतापूर्वक अपलोड गरियो',
+    'import_run' => 'आयात अद्यावधिक गरियो',
+    'import_run_notification' => 'सामग्री सफलतापूर्वक आयात गरियो',
+    'import_delete' => 'आयात हटाइयो',
+    'import_delete_notification' => 'आयात सफलतापूर्वक हटाइयो',
+
+    // Users
+    'user_create' => 'प्रयोगकर्ता सिर्जना गरियो',
+    'user_create_notification' => 'प्रयोगकर्ता सफलतापूर्वक सिर्जना गरियो',
+    'user_update' => 'प्रयोगकर्ता अद्यावधिक गरियो',
+    'user_update_notification' => 'प्रयोगकर्ता सफलतापूर्वक अद्यावधिक गरियो',
+    'user_delete' => 'प्रयोगकर्ता हटाइयो',
+    'user_delete_notification' => 'प्रयोगकर्ता सफलतापूर्वक हटाइयो',
+
+    // API Tokens
+    'api_token_create' => 'API टोकन सिर्जना गरियो',
+    'api_token_create_notification' => 'API टोकन सफलतापूर्वक सिर्जना गरियो',
+    'api_token_update' => 'API टोकन अद्यावधिक गरियो',
+    'api_token_update_notification' => 'API टोकन सफलतापूर्वक अद्यावधिक गरियो',
+    'api_token_delete' => 'API टोकन हटाइयो',
+    'api_token_delete_notification' => 'API टोकन सफलतापूर्वक हटाइयो',
+
+    // Roles
+    'role_create' => 'भूमिका सिर्जना गरियो',
+    'role_create_notification' => 'भूमिका सफलतापूर्वक सिर्जना गरियो',
+    'role_update' => 'भूमिका अद्यावधिक गरियो',
+    'role_update_notification' => 'भूमिका सफलतापूर्वक अद्यावधिक गरियो',
+    'role_delete' => 'भूमिका हटाइयो',
+    'role_delete_notification' => 'भूमिका सफलतापूर्वक हटाइयो',
+
+    // Recycle Bin
+    'recycle_bin_empty' => 'पुनः प्रयोगको डब्बा खाली गरियो',
+    'recycle_bin_restore' => 'पुनः प्रयोगको डब्बाबाट पुनर्स्थापित गरियो',
+    'recycle_bin_destroy' => 'पुनः प्रयोगको डब्बाबाट हटाइयो',
+
+    // Comments
+    'commented_on'                => 'मा टिप्पणी गरियो',
+    'comment_create'              => 'टिप्पणी थपियो',
+    'comment_update'              => 'टिप्पणी अद्यावधिक गरियो',
+    'comment_delete'              => 'टिप्पणी मेटाइयो',
+
+    // Sort Rules
+    'sort_rule_create' => 'क्रम नियम सिर्जना गरियो',
+    'sort_rule_create_notification' => 'क्रम नियम सफलतापूर्वक सिर्जना गरियो',
+    'sort_rule_update' => 'क्रम नियम अद्यावधिक गरियो',
+    'sort_rule_update_notification' => 'क्रम नियम सफलतापूर्वक अद्यावधिक गरियो',
+    'sort_rule_delete' => 'क्रम नियम हटाइयो',
+    'sort_rule_delete_notification' => 'क्रम नियम सफलतापूर्वक हटाइयो',
+
+    // Other
+    'permissions_update'          => 'अनुमतिहरू अद्यावधिक गरियो',
+];
diff --git a/lang/ne/auth.php b/lang/ne/auth.php
new file mode 100644
index 000000000..cd875a226
--- /dev/null
+++ b/lang/ne/auth.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * Authentication Language Lines
+ * The following language lines are used during authentication for various
+ * messages that we need to display to the user.
+ */
+return [
+
+    'failed' => 'यी प्रमाणिकरण जानकारी हाम्रो अभिलेखसँग मेल खाँदैन।',
+    'throttle' => 'लगइन प्रयासहरूको संख्या धेरै भएको छ। कृपया :seconds सेकेन्ड पछि पुनः प्रयास गर्नुहोस्।',
+
+    // Login & Register
+    'sign_up' => 'साइन अप गर्नुहोस्',
+    'log_in' => 'लग इन गर्नुहोस्',
+    'log_in_with' => ':socialDriver मार्फत लगइन गर्नुहोस्',
+    'sign_up_with' => ':socialDriver प्रयोग गरेर साइन अप गर्नुहोस्',
+    'logout' => 'लगआउट',
+
+    'name' => 'नाम',
+    'username' => 'प्रयोगकर्ता नाम',
+    'email' => 'ईमेल',
+    'password' => 'पासवर्ड',
+    'password_confirm' => 'पासवर्ड पक्का गर्नुहोस्',
+    'password_hint' => 'कम्तिमा 8 अङ्कको हुनुपर्छ',
+    'forgot_password' => 'पासवर्ड भुल्नुभयो?',
+    'remember_me' => 'मलाई सम्झनुहोस्',
+    'ldap_email_hint' => 'कृपया यस खाताको लागि प्रयोग गर्नको लागि इमेल प्रविष्ट गर्नुहोस्।',
+    'create_account' => 'खाता बनाउनुहोस्',
+    'already_have_account' => 'तपाईंको पहिले नै खाता छ?',
+    'dont_have_account' => 'के तपाईंको खाता छैन?',
+    'social_login' => 'सामाजिक लगइन',
+    'social_registration' => 'सामाजिक दर्ता',
+    'social_registration_text' => 'अर्को सेवाबाट दर्ता गर्नुहोस् र लगइन गर्नुहोस्।',
+
+    'register_thanks' => 'दर्ता गर्नुभएकोमा धन्यवाद!',
+    'register_confirm' => 'कृपया तपाईंको इमेल जाँच गर्नुहोस् र :appName मा पहुँच पाउनको लागि पुष्टिकरण बटनमा क्लिक गर्नुहोस्।',
+    'registrations_disabled' => 'दर्ता हाल बन्द गरिएको छ',
+    'registration_email_domain_invalid' => 'त्यो इमेल डोमेनलाई यस आवेदनमा पहुँच छैन',
+    'register_success' => 'साइन अप गर्नुभएकोमा धन्यवाद! तपाईं अब दर्ता र लगइन भइसकेका हुनुहुन्छ।',
+
+    // Login auto-initiation
+    'auto_init_starting' => 'लगइन प्रयास गर्दै',
+    'auto_init_starting_desc' => 'हामी तपाईंको प्रमाणीकरण प्रणालीलाई लगइन प्रक्रिया सुरु गर्नका लागि सम्पर्क गर्दैछौं। यदि 5 सेकेन्डपछि प्रगति हुँदैन भने तलको लिङ्कमा क्लिक गर्न प्रयास गर्नुहोस्।',
+    'auto_init_start_link' => 'प्रमाणीकरणसँग अगाडि बढ्नुहोस्',
+
+    // Password Reset
+    'reset_password' => 'पासवर्ड रिसेट गर्नुहोस्',
+    'reset_password_send_instructions' => 'तपाईंको इमेल तल प्रविष्ट गर्नुहोस् र तपाईंलाई पासवर्ड रिसेट लिङ्क सहितको इमेल पठाइनेछ।',
+    'reset_password_send_button' => 'रिसेट लिङ्क पठाउनुहोस्',
+    'reset_password_sent' => ':email इमेलमा पासवर्ड रिसेट लिङ्क पठाइनेछ यदि त्यो इमेल ठेगाना प्रणालीमा फेला पारिन्छ भने।',
+    'reset_password_success' => 'तपाईंको पासवर्ड सफलतापूर्वक रिसेट गरिएको छ।',
+    'email_reset_subject' => ':appName पासवर्ड रिसेट गर्नुहोस्',
+    'email_reset_text' => 'तपाईं यो इमेल प्राप्त गर्दै हुनुहुन्छ किनकि हामीले तपाईंको खाताको लागि पासवर्ड रिसेट अनुरोध प्राप्त गर्यौं।',
+    'email_reset_not_requested' => 'यदि तपाईंले पासवर्ड रिसेट अनुरोध गर्नुभएको छैन भने, अगाडि कुनै कार्य आवश्यक पर्दैन।',
+
+    // Email Confirmation
+    'email_confirm_subject' => ':appName मा तपाईंको इमेल पुष्टि गर्नुहोस्',
+    'email_confirm_greeting' => ':appName मा सामेल हुनुभएकोमा धन्यवाद!',
+    'email_confirm_text' => 'कृपया तलको बटनमा क्लिक गरेर तपाईंको इमेल ठेगाना पुष्टि गर्नुहोस्:',
+    'email_confirm_action' => 'इमेल पुष्टि गर्नुहोस्',
+    'email_confirm_send_error' => 'इमेल पुष्टिकरण आवश्यक छ तर प्रणालीले इमेल पठाउन सकेन। इमेल सही तरिकाले सेटअप गरिएको छ भनी सुनिश्चित गर्न प्रशासकलाई सम्पर्क गर्नुहोस्।',
+    'email_confirm_success' => 'तपाईंको इमेल पुष्टि भएको छ! अब तपाईं यो इमेल ठेगाना प्रयोग गरेर लगइन गर्न सक्षम हुनुहुनेछ।',
+    'email_confirm_resent' => 'पुष्टिकरण इमेल पुनः पठाइएको छ, कृपया तपाईंको इनबक्स जाँच गर्नुहोस्।',
+    'email_confirm_thanks' => 'पुष्टिकरणको लागि धन्यवाद!',
+    'email_confirm_thanks_desc' => 'कृपया केही समय कुर्नुहोस् जबकि तपाईंको पुष्टिकरण प्रक्रिया गरिन्छ। यदि तपाईंलाई 3 सेकेन्ड पछि पुनः रिडिरेक्ट गरिएको छैन भने, तलको "अगाडि बढ्नुहोस्" लिङ्कमा क्लिक गर्नुहोस्।',
+
+    'email_not_confirmed' => 'इमेल ठेगाना पुष्टि गरिएको छैन',
+    'email_not_confirmed_text' => 'तपाईंको इमेल ठेगाना अझै पुष्टि भएको छैन।',
+    'email_not_confirmed_click_link' => 'कृपया तपाईंले दर्ता गर्दा पठाइएको इमेलमा रहेको लिङ्कमा क्लिक गर्नुहोस्।',
+    'email_not_confirmed_resend' => 'यदि तपाईंलाई इमेल भेट्न गाह्रो भइरहेको छ भने, तपाईं तलको फारम द्वारा पुष्टिकरण इमेल पुनः पठाउन सक्नुहुन्छ।',
+    'email_not_confirmed_resend_button' => 'पुष्टिकरण इमेल पुनः पठाउनुहोस्',
+
+    // User Invite
+    'user_invite_email_subject' => ':appName मा सामेल हुनका लागि तपाईंलाई आमन्त्रित गरिएको छ!',
+    'user_invite_email_greeting' => ':appName मा तपाईंको खाता सिर्जना गरिएको छ।',
+    'user_invite_email_text' => 'खाता पासवर्ड सेट गर्न र पहुँच प्राप्त गर्न तलको बटनमा क्लिक गर्नुहोस्:',
+    'user_invite_email_action' => 'खाता पासवर्ड सेट गर्नुहोस्',
+    'user_invite_page_welcome' => ':appName मा स्वागत छ!',
+    'user_invite_page_text' => 'तपाईंको खाता अन्तिम रूप दिन र पहुँच प्राप्त गर्न तपाईंलाई पासवर्ड सेट गर्न आवश्यक छ जुन भविष्यका भ्रमणमा :appName मा लगइन गर्न प्रयोग हुनेछ।',
+    'user_invite_page_confirm_button' => 'पासवर्ड पक्का गर्नुहोस्',
+    'user_invite_success_login' => 'पासवर्ड सेट गरिएको छ, तपाईं अब तपाईंको सेट गरिएको पासवर्ड प्रयोग गरेर :appName मा लगइन गर्न सक्षम हुनुहुनेछ!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'मल्टि-फ्याक्टर प्रमाणीकरण सेटअप गर्नुहोस्',
+    'mfa_setup_desc' => 'तपाईंको प्रयोगकर्ता खाता सुरक्षा थप गर्न मल्टि-फ्याक्टर प्रमाणीकरण सेटअप गर्नुहोस्।',
+    'mfa_setup_configured' => 'पहिले नै कन्फिगर गरिएको',
+    'mfa_setup_reconfigure' => 'पुनः कन्फिगर गर्नुहोस्',
+    'mfa_setup_remove_confirmation' => 'के तपाईं यो मल्टि-फ्याक्टर प्रमाणीकरण विधि हटाउन चाहानुहुन्छ?',
+    'mfa_setup_action' => 'सेटअप',
+    'mfa_backup_codes_usage_limit_warning' => 'तपाईंको ५ भन्दा कम ब्याकअप कोड बाँकी छन्, कृपया नयाँ सेट उत्पन्न गर्नुहोस् र सुरक्षित स्थानमा राख्नुहोस् ताकि तपाईंको खाता लकडाउन नहोस्।',
+    'mfa_option_totp_title' => 'मोबाइल एप',
+    'mfa_option_totp_desc' => 'मल्टि-फ्याक्टर प्रमाणीकरण प्रयोग गर्नको लागि तपाईंलाई Google Authenticator, Authy वा Microsoft Authenticator जस्ता TOTP समर्थित मोबाइल एपको आवश्यकता पर्छ।',
+    'mfa_option_backup_codes_title' => 'ब्याकअप कोड',
+    'mfa_option_backup_codes_desc' => 'एक सेट ब्याकअप कोड उत्पन्न गर्दछ जसलाई तपाईंले लगइन गर्दा आफ्नो पहिचान प्रमाणित गर्न प्रयोग गर्नुहुनेछ। यी सुरक्षित स्थानमा राख्नुहोस्।',
+    'mfa_gen_confirm_and_enable' => 'पुष्टिकरण र सक्षम गर्नुहोस्',
+    'mfa_gen_backup_codes_title' => 'ब्याकअप कोड सेटअप',
+    'mfa_gen_backup_codes_desc' => 'तलको कोडहरूको सूची सुरक्षित स्थानमा राख्नुहोस्। प्रणालीमा पहुँच गर्दा तपाईंले एक कोडलाई दोस्रो प्रमाणीकरण विधिका रूपमा प्रयोग गर्नुहुनेछ।',
+    'mfa_gen_backup_codes_download' => 'कोडहरू डाउनलोड गर्नुहोस्',
+    'mfa_gen_backup_codes_usage_warning' => 'प्रत्येक कोड एक पटक मात्र प्रयोग गर्न सकिन्छ।',
+    'mfa_gen_totp_title' => 'मोबाइल एप सेटअप',
+    'mfa_gen_totp_desc' => 'मल्टि-फ्याक्टर प्रमाणीकरण प्रयोग गर्नको लागि तपाईंलाई Google Authenticator, Authy वा Microsoft Authenticator जस्ता TOTP समर्थित मोबाइल एपको आवश्यकता पर्छ।',
+    'mfa_gen_totp_scan' => 'सुरु गर्नको लागि तलको QR कोड स्क्यान गर्नुहोस्।',
+    'mfa_gen_totp_verify_setup' => 'सेटअप प्रमाणित गर्नुहोस्',
+    'mfa_gen_totp_verify_setup_desc' => 'सुनिश्चित गर्नका लागि एक कोड प्रविष्ट गर्नुहोस्, तपाईंको प्रमाणीकरण एपबाट उत्पन्न गरिएको, तलको इनपुट बक्समा:',
+    'mfa_gen_totp_provide_code_here' => 'तपाईंको एपबाट उत्पन्न गरिएको कोड यहाँ प्रदान गर्नुहोस्।',
+    'mfa_verify_access' => 'पहुँच प्रमाणित गर्नुहोस्',
+    'mfa_verify_access_desc' => 'तपाईंको प्रयोगकर्ता खाता थप प्रमाणीकरणको माध्यमबाट आफ्नो पहिचान प्रमाणित गर्नको लागि आवश्यक छ। कृपया अगाडि बढ्नको लागि तपाईंको कन्फिगर गरिएको विधि प्रयोग गर्नुहोस्।',
+    'mfa_verify_no_methods' => 'कुनै तरिका कन्फिगर गरिएको छैन',
+    'mfa_verify_no_methods_desc' => 'तपाईंको खातामा मल्टि-फ्याक्टर प्रमाणीकरण विधिहरू फेला परेका छैनन्। तपाईंलाई पहुँच प्राप्त गर्न कम्तिमा एक विधि सेटअप गर्न आवश्यक छ।',
+    'mfa_verify_use_totp' => 'मोबाइल एप प्रयोग गरेर प्रमाणित गर्नुहोस्',
+    'mfa_verify_use_backup_codes' => 'ब्याकअप कोड प्रयोग गरेर प्रमाणित गर्नुहोस्',
+    'mfa_verify_backup_code' => 'ब्याकअप कोड',
+    'mfa_verify_backup_code_desc' => 'तल तपाईंको बाँकी रहेका ब्याकअप कोडहरू मध्ये एउटा प्रविष्ट गर्नुहोस्:',
+    'mfa_verify_backup_code_enter_here' => 'यहाँ ब्याकअप कोड प्रविष्ट गर्नुहोस्',
+    'mfa_verify_totp_desc' => 'तपाईंको मोबाइल एपबाट उत्पन्न गरिएको कोड तल प्रविष्ट गर्नुहोस्:',
+    'mfa_setup_login_notification' => 'मल्टि-फ्याक्टर विधि कन्फिगर गरिएको छ, कृपया अब कन्फिगर गरिएको विधि प्रयोग गरेर फेरि लगइन गर्नुहोस्।',
+];
diff --git a/lang/ne/common.php b/lang/ne/common.php
new file mode 100644
index 000000000..696ddc6b4
--- /dev/null
+++ b/lang/ne/common.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'रद्द गर्नुहोस्',
+    'close' => 'बन्द गर्नुहोस्',
+    'confirm' => 'पुष्टि गर्नुहोस्',
+    'back' => 'फिर्ता',
+    'save' => 'सेभ गर्नुहोस्',
+    'continue' => 'जारी राख्नुहोस्',
+    'select' => 'छान्नुहोस्',
+    'toggle_all' => 'सबै टगल गर्नुहोस्',
+    'more' => 'थप',
+
+    // Form Labels
+    'name' => 'नाम',
+    'description' => 'विवरण',
+    'role' => 'भूमिका',
+    'cover_image' => 'आवरण चित्र',
+    'cover_image_description' => 'यो चित्र करिब 440x250px हुनुपर्छ, यद्यपि यो प्रयोगकर्ता इन्टरफेसमा आवश्यकताअनुसार लचिलो रूपमा स्केल र काटिने भएकाले देखिने वास्तविक आकार फरक हुन सक्छ।',
+
+    // Actions
+    'actions' => 'कार्यहरू',
+    'view' => 'हेर्नुहोस्',
+    'view_all' => 'सबै हेर्नुहोस्',
+    'new' => 'नयाँ',
+    'create' => 'सिर्जना गर्नुहोस्',
+    'update' => 'अद्यावधिक गर्नुहोस्',
+    'edit' => 'सम्पादन गर्नुहोस्',
+    'archive' => 'अभिलेख राख्नुहोस्',
+    'unarchive' => 'अभिलेख हटाउनुहोस्',
+    'sort' => 'क्रमबद्ध गर्नुहोस्',
+    'move' => 'सार्नुहोस्',
+    'copy' => 'प्रतिलिपि गर्नुहोस्',
+    'reply' => 'जवाफ दिनुहोस्',
+    'delete' => 'हटाउनुहोस्',
+    'delete_confirm' => 'हटाउने पुष्टि गर्नुहोस्',
+    'search' => 'खोज्नुहोस्',
+    'search_clear' => 'खोज हटाउनुहोस्',
+    'reset' => 'रीसेट गर्नुहोस्',
+    'remove' => 'हटाउनुहोस्',
+    'add' => 'थप्नुहोस्',
+    'configure' => 'कन्फिगर गर्नुहोस्',
+    'manage' => 'व्यवस्थापन गर्नुहोस्',
+    'fullscreen' => 'पूर्ण स्क्रिन',
+    'favourite' => 'मनपर्ने',
+    'unfavourite' => 'मनपर्नेबाट हटाउनुहोस्',
+    'next' => 'अर्को',
+    'previous' => 'अघिल्लो',
+    'filter_active' => 'सक्रिय फिल्टर:',
+    'filter_clear' => 'फिल्टर हटाउनुहोस्',
+    'download' => 'डाउनलोड गर्नुहोस्',
+    'open_in_tab' => 'ट्याबमा खोल्नुहोस्',
+    'open' => 'खोल्नुहोस्',
+
+    // Sort Options
+    'sort_options' => 'क्रमबद्ध विकल्पहरू',
+    'sort_direction_toggle' => 'क्रमबद्ध दिशा टगल',
+    'sort_ascending' => 'बढ्दो क्रममा क्रमबद्ध गर्नुहोस्',
+    'sort_descending' => 'घट्दो क्रममा क्रमबद्ध गर्नुहोस्',
+    'sort_name' => 'नाम',
+    'sort_default' => 'पूर्वनिर्धारित',
+    'sort_created_at' => 'सिर्जना मिति',
+    'sort_updated_at' => 'अद्यावधिक मिति',
+
+    // Misc
+    'deleted_user' => 'हटाइएको प्रयोगकर्ता',
+    'no_activity' => 'देखाउनका लागि कुनै गतिविधि छैन',
+    'no_items' => 'कुनै वस्तुहरू उपलब्ध छैनन्',
+    'back_to_top' => 'शीर्षमा फर्कनुहोस्',
+    'skip_to_main_content' => 'मुख्य सामग्रीमा जानुहोस्',
+    'toggle_details' => 'विवरण टगल गर्नुहोस्',
+    'toggle_thumbnails' => 'थम्बनेल टगल गर्नुहोस्',
+    'details' => 'विवरण',
+    'grid_view' => 'ग्रिड दृश्य',
+    'list_view' => 'सूची दृश्य',
+    'default' => 'पूर्वनिर्धारित',
+    'breadcrumb' => 'ब्रेडक्रम्ब',
+    'status' => 'स्थिति',
+    'status_active' => 'सक्रिय',
+    'status_inactive' => 'निष्क्रिय',
+    'never' => 'कहिल्यै होइन',
+    'none' => 'कुनै पनि होइन',
+
+    // Header
+    'homepage' => 'गृहपृष्ठ',
+    'header_menu_expand' => 'हेडर मेनु विस्तार गर्नुहोस्',
+    'profile_menu' => 'प्रोफाइल मेनु',
+    'view_profile' => 'प्रोफाइल हेर्नुहोस्',
+    'edit_profile' => 'प्रोफाइल सम्पादन गर्नुहोस्',
+    'dark_mode' => 'गाढा मोड',
+    'light_mode' => 'हल्का मोड',
+    'global_search' => 'विश्वव्यापी खोज',
+
+    // Layout tabs
+    'tab_info' => 'जानकारी',
+    'tab_info_label' => 'ट्याब: द्वितीय जानकारी देखाउनुहोस्',
+    'tab_content' => 'सामग्री',
+    'tab_content_label' => 'ट्याब: प्राथमिक सामग्री देखाउनुहोस्',
+
+    // Email Content
+    'email_action_help' => 'यदि तपाईं ":actionText" बटनमा क्लिक गर्न समस्या भइरहेको छ भने, तलको URL आफ्नो वेब ब्राउजरमा कपी गरेर पेस्ट गर्नुहोस्:',
+    'email_rights' => 'सर्वाधिकार सुरक्षित',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'गोपनीयता नीति',
+    'terms_of_service' => 'सेवा सर्तहरू',
+
+    // OpenSearch
+    'opensearch_description' => ':appName खोज्नुहोस्',
+];
diff --git a/lang/ne/components.php b/lang/ne/components.php
new file mode 100644
index 000000000..d488842e4
--- /dev/null
+++ b/lang/ne/components.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'चित्र चयन गर्नुहोस्',
+    'image_list' => 'चित्र सूची',
+    'image_details' => 'चित्र विवरण',
+    'image_upload' => 'चित्र अपलोड गर्नुहोस्',
+    'image_intro' => 'यहाँ तपाईंले पहिले अपलोड गरिएका चित्रहरू चयन र व्यवस्थापन गर्न सक्नुहुन्छ।',
+    'image_intro_upload' => '"चित्र अपलोड गर्नुहोस्" बटन प्रयोग गरेर वा चित्र फाइललाई यो विन्डोमा तानेर नयाँ चित्र अपलोड गर्नुहोस्।',
+    'image_all' => 'सबै',
+    'image_all_title' => 'सबै चित्रहरू हेर्नुहोस्',
+    'image_book_title' => 'यस पुस्तकमा अपलोड गरिएका चित्रहरू हेर्नुहोस्',
+    'image_page_title' => 'यस पृष्ठमा अपलोड गरिएका चित्रहरू हेर्नुहोस्',
+    'image_search_hint' => 'चित्र नामद्वारा खोज्नुहोस्',
+    'image_uploaded' => 'अपलोड गरिएको :uploadedDate',
+    'image_uploaded_by' => ':userName द्वारा अपलोड गरिएको',
+    'image_uploaded_to' => ':pageLink मा अपलोड गरिएको',
+    'image_updated' => 'अद्यावधिक गरिएको :updateDate',
+    'image_load_more' => 'थप लोड गर्नुहोस्',
+    'image_image_name' => 'चित्र नाम',
+    'image_delete_used' => 'यो चित्र तलका पृष्ठहरूमा प्रयोग भइरहेको छ।',
+    'image_delete_confirm_text' => 'के तपाईं यो चित्र मेटाउन निश्चित हुनुहुन्छ?',
+    'image_select_image' => 'चित्र चयन गर्नुहोस्',
+    'image_dropzone' => 'चित्र ड्रप गर्नुहोस् वा अपलोड गर्न यहाँ क्लिक गर्नुहोस्',
+    'image_dropzone_drop' => 'अपलोड गर्न यहाँ चित्र ड्रप गर्नुहोस्',
+    'images_deleted' => 'चित्रहरू मेटाइयो',
+    'image_preview' => 'चित्र पूर्वावलोकन',
+    'image_upload_success' => 'चित्र सफलतापूर्वक अपलोड गरियो',
+    'image_update_success' => 'चित्र विवरण सफलतापूर्वक अद्यावधिक गरियो',
+    'image_delete_success' => 'चित्र सफलतापूर्वक मेटाइयो',
+    'image_replace' => 'चित्र प्रतिस्थापन गर्नुहोस्',
+    'image_replace_success' => 'चित्र फाइल सफलतापूर्वक अद्यावधिक गरियो',
+    'image_rebuild_thumbs' => 'आकारका भेरिएसनहरू पुनर्निर्माण गर्नुहोस्',
+    'image_rebuild_thumbs_success' => 'चित्र आकार भेरिएसनहरू सफलतापूर्वक पुनर्निर्माण गरियो!',
+
+    // Code Editor
+    'code_editor' => 'कोड सम्पादन गर्नुहोस्',
+    'code_language' => 'कोड भाषा',
+    'code_content' => 'कोड सामग्री',
+    'code_session_history' => 'सेसन इतिहास',
+    'code_save' => 'कोड सेभ गर्नुहोस्',
+];
diff --git a/lang/ne/editor.php b/lang/ne/editor.php
new file mode 100644
index 000000000..f3b760ba8
--- /dev/null
+++ b/lang/ne/editor.php
@@ -0,0 +1,182 @@
+<?php
+/**
+ * Page Editor Lines
+ * Contains text strings used within the user interface of the
+ * WYSIWYG page editor. Some Markdown editor strings may still
+ * exist in the 'entities' file instead since this was added later.
+ */
+return [
+    // General editor terms
+    'general' => 'सामान्य',
+    'advanced' => 'उन्नत',
+    'none' => 'कुनै पनि छैन',
+    'cancel' => 'रद्द गर्नुहोस्',
+    'save' => 'सेभ गर्नुहोस्',
+    'close' => 'बन्द गर्नुहोस्',
+    'apply' => 'लागु गर्नुहोस्',
+    'undo' => 'पूर्ववत् गर्नुहोस्',
+    'redo' => 'पुन: गर्नुहोस्',
+    'left' => 'बायाँ',
+    'center' => 'केन्द्र',
+    'right' => 'दायाँ',
+    'top' => 'माथि',
+    'middle' => 'बीच',
+    'bottom' => 'तल',
+    'width' => 'चौडाइ',
+    'height' => 'उचाइ',
+    'More' => 'थप',
+    'select' => 'छान्नुहोस्...',
+
+    // Toolbar
+    'formats' => 'ढाँचा',
+    'header_large' => 'ठुलो शीर्षक',
+    'header_medium' => 'मध्यम शीर्षक',
+    'header_small' => 'सानो शीर्षक',
+    'header_tiny' => 'अत्यन्त सानो शीर्षक',
+    'paragraph' => 'प्याराग्राफ',
+    'blockquote' => 'ब्लकउद्धरण',
+    'inline_code' => 'इनलाइन कोड',
+    'callouts' => 'कौलआउटहरू',
+    'callout_information' => 'सूचना',
+    'callout_success' => 'सफलता',
+    'callout_warning' => 'चेतावनी',
+    'callout_danger' => 'खतरा',
+    'bold' => 'मोठो अक्षर',
+    'italic' => 'तेरियो',
+    'underline' => 'रेखाङ्कन',
+    'strikethrough' => 'रेखाले काटिएको',
+    'superscript' => 'सुपरस्क्रिप्ट',
+    'subscript' => 'सबस्क्रिप्ट',
+    'text_color' => 'पाठको रंग',
+    'highlight_color' => 'Highlight color',
+    'custom_color' => 'अनुकूलित रंग',
+    'remove_color' => 'रंग हटाउनुहोस्',
+    'background_color' => 'पृष्ठभूमि रंग',
+    'align_left' => 'बायाँ संरेखण',
+    'align_center' => 'केन्द्र संरेखण',
+    'align_right' => 'दायाँ संरेखण',
+    'align_justify' => 'समायोजन',
+    'list_bullet' => 'बुलेट सूची',
+    'list_numbered' => 'संख्याबद्ध सूची',
+    'list_task' => 'कार्य सूची',
+    'indent_increase' => 'इनडेन्ट बढाउनुहोस्',
+    'indent_decrease' => 'इनडेन्ट घटाउनुहोस्',
+    'table' => 'टेबल',
+    'insert_image' => 'चित्र राख्नुहोस्',
+    'insert_image_title' => 'चित्र राख्नुहोस्/सम्पादन गर्नुहोस्',
+    'insert_link' => 'लिंक राख्नुहोस्/सम्पादन गर्नुहोस्',
+    'insert_link_title' => 'लिंक राख्नुहोस्/सम्पादन गर्नुहोस्',
+    'insert_horizontal_line' => 'क्षैतिज रेखा राख्नुहोस्',
+    'insert_code_block' => 'कोड ब्लक राख्नुहोस्',
+    'edit_code_block' => 'कोड ब्लक सम्पादन गर्नुहोस्',
+    'insert_drawing' => 'ड्रइङ राख्नुहोस्/सम्पादन गर्नुहोस्',
+    'drawing_manager' => 'ड्रइङ व्यवस्थापक',
+    'insert_media' => 'मिडिया राख्नुहोस्/सम्पादन गर्नुहोस्',
+    'insert_media_title' => 'मिडिया राख्नुहोस्/सम्पादन गर्नुहोस्',
+    'clear_formatting' => 'ढाँचा सफा गर्नुहोस्',
+    'source_code' => 'स्रोत कोड',
+    'source_code_title' => 'स्रोत कोड',
+    'fullscreen' => 'पूर्ण स्क्रीन',
+    'image_options' => 'चित्र विकल्प',
+
+    // Tables
+    'table_properties' => 'टेबल गुणहरू',
+    'table_properties_title' => 'टेबल गुणहरू',
+    'delete_table' => 'टेबल मेटाउनुहोस्',
+    'table_clear_formatting' => 'टेबलको ढाँचा सफा गर्नुहोस्',
+    'resize_to_contents' => 'सामग्री अनुसार आकार मिलाउनुहोस्',
+    'row_header' => 'पङ्क्ति शीर्षक',
+    'insert_row_before' => 'अगाडिको पङ्क्ति राख्नुहोस्',
+    'insert_row_after' => 'पछिको पङ्क्ति राख्नुहोस्',
+    'delete_row' => 'पङ्क्ति मेटाउनुहोस्',
+    'insert_column_before' => 'अगाडिको स्तम्भ राख्नुहोस्',
+    'insert_column_after' => 'पछिको स्तम्भ राख्नुहोस्',
+    'delete_column' => 'स्तम्भ मेटाउनुहोस्',
+    'table_cell' => 'सेल',
+    'table_row' => 'पङ्क्ति',
+    'table_column' => 'स्तम्भ',
+    'cell_properties' => 'सेल गुणहरू',
+    'cell_properties_title' => 'सेल गुणहरू',
+    'cell_type' => 'सेल प्रकार',
+    'cell_type_cell' => 'सेल',
+    'cell_scope' => 'स्कोप',
+    'cell_type_header' => 'शीर्षक सेल',
+    'merge_cells' => 'सेल मर्ज गर्नुहोस्',
+    'split_cell' => 'सेल विभाजन गर्नुहोस्',
+    'table_row_group' => 'पङ्क्ति समूह',
+    'table_column_group' => 'स्तम्भ समूह',
+    'horizontal_align' => 'क्षैतिज संरेखण',
+    'vertical_align' => 'उर्ध्वाधर संरेखण',
+    'border_width' => 'बोर्डर चौडाइ',
+    'border_style' => 'बोर्डर शैली',
+    'border_color' => 'बोर्डर रंग',
+    'row_properties' => 'पङ्क्ति गुणहरू',
+    'row_properties_title' => 'पङ्क्ति गुणहरू',
+    'cut_row' => 'पङ्क्ति काट्नुहोस्',
+    'copy_row' => 'पङ्क्ति कपी गर्नुहोस्',
+    'paste_row_before' => 'अगाडिको पङ्क्तिमा पेस्ट गर्नुहोस्',
+    'paste_row_after' => 'पछिको पङ्क्तिमा पेस्ट गर्नुहोस्',
+    'row_type' => 'पङ्क्ति प्रकार',
+    'row_type_header' => 'शीर्षक',
+    'row_type_body' => 'शरीर',
+    'row_type_footer' => 'तल',
+    'alignment' => 'संरेखण',
+    'cut_column' => 'स्तम्भ काट्नुहोस्',
+    'copy_column' => 'स्तम्भ कपी गर्नुहोस्',
+    'paste_column_before' => 'अगाडिको स्तम्भमा पेस्ट गर्नुहोस्',
+    'paste_column_after' => 'पछिको स्तम्भमा पेस्ट गर्नुहोस्',
+    'cell_padding' => 'सेल प्याडिङ',
+    'cell_spacing' => 'सेल स्पेसिङ',
+    'caption' => 'क्याप्सन',
+    'show_caption' => 'क्याप्सन देखाउनुहोस्',
+    'constrain' => 'अनुपात सीमित गर्नुहोस्',
+    'cell_border_solid' => 'ठोस',
+    'cell_border_dotted' => 'डटेड',
+    'cell_border_dashed' => 'ड्यास्ड',
+    'cell_border_double' => 'डबल',
+    'cell_border_groove' => 'ग्रूभ',
+    'cell_border_ridge' => 'रिज',
+    'cell_border_inset' => 'इनसेट',
+    'cell_border_outset' => 'आउटसेट',
+    'cell_border_none' => 'कुनै पनि छैन',
+    'cell_border_hidden' => 'लुकेको',
+
+    // Images, links, details/summary & embed
+    'source' => 'स्रोत',
+    'alt_desc' => 'वैकल्पिक विवरण',
+    'embed' => 'एम्बेड',
+    'paste_embed' => 'तपाईंको एम्बेड कोड तल टाँस्नुहोस्:',
+    'url' => 'URL',
+    'text_to_display' => 'देखाउने पाठ',
+    'title' => 'शीर्षक',
+    'browse_links' => 'लिंकहरू ब्राउज गर्नुहोस्',
+    'open_link' => 'लिंक खोल्नुहोस्',
+    'open_link_in' => 'लिंक खोल्नुहोस् ...',
+    'open_link_current' => 'हालको विन्डो',
+    'open_link_new' => 'नयाँ विन्डो',
+    'remove_link' => 'लिंक हटाउनुहोस्',
+    'insert_collapsible' => 'टुंगो लाग्ने ब्लक राख्नुहोस्',
+    'collapsible_unwrap' => 'अनर्याप गर्नुहोस्',
+    'edit_label' => 'लेबल सम्पादन गर्नुहोस्',
+    'toggle_open_closed' => 'खोल्ने/बन्द गर्ने टगल गर्नुहोस्',
+    'collapsible_edit' => 'टुंगो लाग्ने ब्लक सम्पादन गर्नुहोस्',
+    'toggle_label' => 'लेबल टगल गर्नुहोस्',
+
+    // About view
+    'about' => 'संपादकको बारेमा',
+    'about_title' => 'WYSIWYG संपादकको बारेमा',
+    'editor_license' => 'संपादक अनुमति र कपीराइट',
+    'editor_lexical_license' => 'यो संपादक :lexicalLink को फोर्कको रूपमा निर्माण गरिएको हो जुन MIT लाइसेन्स अन्तर्गत वितरण गरिएको छ।',
+    'editor_lexical_license_link' => 'पूरा लाइसेन्स विवरण यहाँ भेट्न सकिन्छ।',
+    'editor_tiny_license' => 'यो संपादक :tinyLink प्रयोग गरेर निर्माण गरिएको हो जुन MIT लाइसेन्स अन्तर्गत उपलब्ध छ।',
+    'editor_tiny_license_link' => 'TinyMCE को कपीराइट र लाइसेन्स विवरण यहाँ भेट्न सकिन्छ।',
+    'save_continue' => 'पृष्ठ सेभ गरी जारी राख्नुहोस्',
+    'callouts_cycle' => '(प्रकारहरू टगल गर्न थिचिरहनुहोस्)',
+    'link_selector' => 'सामग्रीमा लिंक',
+    'shortcuts' => 'सर्टकटहरू',
+    'shortcut' => 'सर्टकट',
+    'shortcuts_intro' => 'संपादकमा निम्न सर्टकटहरू उपलब्ध छन्:',
+    'windows_linux' => '(विन्डोज/लिनक्स)',
+    'mac' => '(म्याक)',
+    'description' => 'विवरण',
+];
diff --git a/lang/ne/entities.php b/lang/ne/entities.php
new file mode 100644
index 000000000..88b202dee
--- /dev/null
+++ b/lang/ne/entities.php
@@ -0,0 +1,476 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'हालैमा सिर्जना गरिएको',
+    'recently_created_pages' => 'हालैमा सिर्जना गरिएका पाना',
+    'recently_updated_pages' => 'हालैमा अद्यावधिक गरिएका पाना',
+    'recently_created_chapters' => 'हालैमा सिर्जना गरिएका अध्यायहरू',
+    'recently_created_books' => 'हालैमा सिर्जना गरिएका पुस्तकहरू',
+    'recently_created_shelves' => 'हालैमा सिर्जना गरिएका दराजहरू',
+    'recently_update' => 'हालैमा अद्यावधिक गरिएको',
+    'recently_viewed' => 'हालैमा हेरिएको',
+    'recent_activity' => 'हालको गतिविधि',
+    'create_now' => 'अहिले सिर्जना गर्नुहोस्',
+    'revisions' => 'संशोधनहरू',
+    'meta_revision' => 'संशोधन #:revisionCount',
+    'meta_created' => 'सिर्जना गरिएको :timeLength',
+    'meta_created_name' => ':user द्वारा सिर्जना गरिएको :timeLength',
+    'meta_updated' => 'अद्यावधिक गरिएको :timeLength',
+    'meta_updated_name' => ':user द्वारा अद्यावधिक गरिएको :timeLength',
+    'meta_owned_name' => ':user द्वारा स्वामित्व गरिएको',
+    'meta_reference_count' => ':count वस्तु द्वारा सन्दर्भित|:count वस्तुहरू द्वारा सन्दर्भित',
+    'entity_select' => 'इकाई चयन',
+    'entity_select_lack_permission' => 'तपाईंलाई यो वस्तु चयन गर्नको लागि आवश्यक अनुमति छैन',
+    'images' => 'छविहरू',
+    'my_recent_drafts' => 'मेरो हालका मस्यौदाहरू',
+    'my_recently_viewed' => 'मेरो हालै हेरिएका पाना',
+    'my_most_viewed_favourites' => 'मेरो सबैभन्दा हेरिएका मनपर्ने',
+    'my_favourites' => 'मेरो मनपर्ने',
+    'no_pages_viewed' => 'तपाईंले कुनै पाना हेरिसकेको छैन',
+    'no_pages_recently_created' => 'हालै कुनै पाना सिर्जना गरिएको छैन',
+    'no_pages_recently_updated' => 'हालै कुनै पाना अद्यावधिक गरिएको छैन',
+    'export' => 'निर्यात',
+    'export_html' => 'समाविष्ट वेब फाइल',
+    'export_pdf' => 'PDF फाइल',
+    'export_text' => 'साधारण टेक्स्ट फाइल',
+    'export_md' => 'Markdown फाइल',
+    'export_zip' => 'पोर्टेबल ZIP',
+    'default_template' => 'पूर्वनिर्धारित पृष्ठ ढाँचा',
+    'default_template_explain' => 'यस वस्तु भित्र सिर्जना गरिएका सबै पानाहरूको लागि पूर्वनिर्धारित सामग्रीको रूपमा प्रयोग हुने पृष्ठ ढाँचाको चयन गर्नुहोस्। ध्यान दिनुहोस् कि यो केवल प्रयोगकर्ता चयन गरेको ढाँचाको पृष्ठलाई हेर्ने अनुमति पाउँदा मात्र लागू हुनेछ।',
+    'default_template_select' => 'पृष्ठ ढाँचा चयन गर्नुहोस्',
+    'import' => 'आयात',
+    'import_validate' => 'आयात प्रमाणित गर्नुहोस्',
+    'import_desc' => 'पुस्तकहरू, अध्यायहरू र पृष्ठहरूलाई पोर्टेबल ZIP निर्यातको माध्यमबाट आयात गर्नुहोस्, चाहे त्यसैको, वा अन्य कुनै उदाहरणको। ZIP फाइल चयन गर्न जारी राख्नुहोस्। फाइल अपलोड र प्रमाणित भएपछि, तपाईंलाई आयातको कन्फिगर र पुष्टि गर्ने विकल्प हुनेछ।',
+    'import_zip_select' => 'अपलोड गर्न ZIP फाइल चयन गर्नुहोस्',
+    'import_zip_validation_errors' => 'प्रदान गरिएको ZIP फाइल प्रमाणित गर्दा निम्न त्रुटिहरू भेटिएका छन्:',
+    'import_pending' => 'पर्खाइमा रहेका आयातहरू',
+    'import_pending_none' => 'कुनै आयात सुरू गरिएको छैन।',
+    'import_continue' => 'आयात जारी राख्नुहोस्',
+    'import_continue_desc' => 'अपलोड गरिएको ZIP फाइलबाट आयात गरिने सामग्रीको समीक्षा गर्नुहोस्। तयार भएपछि, आयात सञ्चालन गर्नुहोस् र यसको सामग्री यस प्रणालीमा थप्नुहोस्। सफल आयातपछि, अपलोड गरिएको ZIP आयात फाइल स्वचालित रूपमा मेटाइनेछ।',
+    'import_details' => 'आयात विवरण',
+    'import_run' => 'आयात सञ्चालन गर्नुहोस्',
+    'import_size' => ':size आयात ZIP साइज',
+    'import_uploaded_at' => 'अपलोड गरिएको :relativeTime',
+    'import_uploaded_by' => 'द्वारा अपलोड गरिएको',
+    'import_location' => 'आयात स्थान',
+    'import_location_desc' => 'आयात गरिएको सामग्रीको लागि लक्ष्य स्थान चयन गर्नुहोस्। तपाईंले चयन गरेको स्थानमा सिर्जना गर्नको लागि तपाईंलाई सम्बन्धित अनुमतिहरू आवश्यक पर्नेछन्।',
+    'import_delete_confirm' => 'के तपाईं पक्का हुनुहुन्छ कि तपाईं यो आयात मेट्न चाहनुहुन्छ?',
+    'import_delete_desc' => 'यो अपलोड गरिएको आयात ZIP फाइल मेट्नेछ, र यो कार्य नकारात्मक हुन सक्दैन।',
+    'import_errors' => 'आयात त्रुटिहरू',
+    'import_errors_desc' => 'आयात प्रयासको क्रममा निम्न त्रुटिहरू उत्पन्न भएका छन्:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
+
+    // Permissions and restrictions
+    'permissions' => 'अनुमतिहरू',
+    'permissions_desc' => 'यहाँ अनुमतिहरू सेट गर्नुहोस् जसले प्रयोगकर्ता भूमिकाद्वारा प्रदान गरिएका डिफल्ट अनुमतिहरूलाई ओभरराइड गर्दछ।',
+    'permissions_book_cascade' => 'पुस्तकहरूमा सेट गरिएका अनुमतिहरू स्वचालित रूपमा सन्तान अध्यायहरू र पृष्ठहरूमा लागू हुनेछन्, जबसम्म तिनीहरूले आफ्नै अनुमतिहरू परिभाषित नगरेका हुँदैन।',
+    'permissions_chapter_cascade' => 'अध्याीयमा सेट गरिएका अनुमतिहरू स्वचालित रूपमा सन्तान पृष्ठहरूमा लागू हुनेछन्, जबसम्म तिनीहरूले आफ्नै अनुमतिहरू परिभाषित नगरेका हुँदैन।',
+    'permissions_save' => 'अनुमतिहरू बचत गर्नुहोस्',
+    'permissions_owner' => 'स्वामी',
+    'permissions_role_everyone_else' => 'अरु सबै',
+    'permissions_role_everyone_else_desc' => 'प्रयोगकर्ता भूमिकामा विशेष रूपमा ओभरराइड नगरेको सबैका लागि अनुमतिहरू सेट गर्नुहोस्।',
+    'permissions_role_override' => 'भूमिकाका लागि अनुमतिहरू ओभरराइड गर्नुहोस्',
+    'permissions_inherit_defaults' => 'डिफल्टहरू मर्नुहोस्',
+
+    // Search
+    'search_results' => 'खोज परिणामहरू',
+    'search_total_results_found' => ':count परिणाम फेला परे|:count कुल परिणामहरू फेला परे',
+    'search_clear' => 'खोज मेटाउनुहोस्',
+    'search_no_pages' => 'कुनै पाना यस खोजसँग मेल खाएका छैनन्',
+    'search_for_term' => ':term को लागि खोजी गर्नुहोस्',
+    'search_more' => 'थप परिणामहरू',
+    'search_advanced' => 'उन्नत खोजी',
+    'search_terms' => 'खोजी शब्दहरू',
+    'search_content_type' => 'सामग्री प्रकार',
+    'search_exact_matches' => 'सटीक मेलहरू',
+    'search_tags' => 'ट्याग खोजी',
+    'search_options' => 'विकल्पहरू',
+    'search_viewed_by_me' => 'मैले हेरेको',
+    'search_not_viewed_by_me' => 'मैले नहेरेको',
+    'search_permissions_set' => 'अनुमतिहरू सेट गरिएका',
+    'search_created_by_me' => 'मैले सिर्जना गरेको',
+    'search_updated_by_me' => 'मैले अद्यावधिक गरेको',
+    'search_owned_by_me' => 'मैले स्वामित्व गरेको',
+    'search_date_options' => 'मिति विकल्पहरू',
+    'search_updated_before' => 'अद्यावधिक गरिएको अघि',
+    'search_updated_after' => 'अद्यावधिक गरिएको पछि',
+    'search_created_before' => 'सिर्जना गरिएको अघि',
+    'search_created_after' => 'सिर्जना गरिएको पछि',
+    'search_set_date' => 'मिति सेट गर्नुहोस्',
+    'search_update' => 'खोज अपडेट गर्नुहोस्',
+
+    // Shelves
+    'shelf' => 'दराज',
+    'shelves' => 'दराजहरू',
+    'x_shelves' => ':count दराज|:count दराजहरू',
+    'shelves_empty' => 'कुनै दराज सिर्जना गरिएको छैन',
+    'shelves_create' => 'नयाँ दराज सिर्जना गर्नुहोस्',
+    'shelves_popular' => 'लोकप्रिय दराजहरू',
+    'shelves_new' => 'नयाँ दराजहरू',
+    'shelves_new_action' => 'नयाँ दराज',
+    'shelves_popular_empty' => 'यहाँ सबैभन्दा लोकप्रिय दराजहरू देखिनेछन्।',
+    'shelves_new_empty' => 'यहाँ सबैभन्दा नयाँ सिर्जना गरिएका दराजहरू देखिनेछन्।',
+    'shelves_save' => 'दराज बचत गर्नुहोस्',
+    'shelves_books' => 'यस दराजमा पुस्तकहरू',
+    'shelves_add_books' => 'यस दराजमा पुस्तकहरू थप्नुहोस्',
+    'shelves_drag_books' => 'पुस्तकहरू तल तान्नुहोस् यस दराजमा थप्नका लागि',
+    'shelves_empty_contents' => 'यस दराजमा कुनै पुस्तकहरू असाइन गरिएको छैन',
+    'shelves_edit_and_assign' => 'दराज सम्पादन गरेर पुस्तकहरू असाइन गर्नुहोस्',
+    'shelves_edit_named' => 'दराज सम्पादन गर्नुहोस् :name',
+    'shelves_edit' => 'दराज सम्पादन गर्नुहोस्',
+    'shelves_delete' => 'दराज मेट्नुहोस्',
+    'shelves_delete_named' => 'दराज मेट्नुहोस् :name',
+    'shelves_delete_explain' => "यसले ':name' नामक दराज मेट्नेछ। समावेश गरिएका पुस्तकहरू मेटिने छैनन्।",
+    'shelves_delete_confirmation' => 'के तपाईं यकिन हुनुहुन्छ कि तपाईं यस दराजलाई मेट्न चाहनुहुन्छ?',
+    'shelves_permissions' => 'दराज अनुमतिहरू',
+    'shelves_permissions_updated' => 'दराज अनुमतिहरू अद्यावधिक गरिएका',
+    'shelves_permissions_active' => 'दराज अनुमतिहरू सक्रिय',
+    'shelves_permissions_cascade_warning' => 'दराजमा सेट गरिएका अनुमतिहरू स्वचालित रूपमा समाविष्ट पुस्तकहरूमा क्यास्केड हुँदैनन्। यो कारणले कि पुस्तक एक भन्दा धेरै दराजमा अस्तित्वमा हुन सक्छ।',
+    'shelves_permissions_create' => 'दराज सिर्जना अनुमतिहरू केवल बालक पुस्तकहरूमा अनुमतिहरूको कपी गर्नको लागि प्रयोग गरिन्छ। यी अनुमतिहरू पुस्तक सिर्जना गर्नको लागि छैन।',
+    'shelves_copy_permissions_to_books' => 'पुस्तकहरूमा अनुमतिहरूको कपी गर्नुहोस्',
+    'shelves_copy_permissions' => 'अनुमतिहरूको कपी गर्नुहोस्',
+    'shelves_copy_permissions_explain' => 'यसले यस दराजको वर्तमान अनुमतिहरूलाई यसमा समावेश गरिएका सबै पुस्तकहरूमा लागू गर्नेछ। कृपया सुनिश्चित गर्नुहोस् कि कुनै पनि परिवर्तनहरू सेभ भएका छन्।',
+    'shelves_copy_permission_success' => ':count पुस्तकहरूमा दराज अनुमतिहरू कपी गरिएका',
+
+    // Books
+    'book' => 'पुस्तक',
+    'books' => 'पुस्तकहरू',
+    'x_books' => ':count पुस्तक|:count पुस्तकहरू',
+    'books_empty' => 'कुनै पुस्तकहरू सिर्जना गरिएका छैनन्',
+    'books_popular' => 'लोकप्रिय पुस्तकहरू',
+    'books_recent' => 'हालका पुस्तकहरू',
+    'books_new' => 'नयाँ पुस्तकहरू',
+    'books_new_action' => 'नयाँ पुस्तक',
+    'books_popular_empty' => 'यहाँ सबैभन्दा लोकप्रिय पुस्तकहरू देखा पर्नेछन्।',
+    'books_new_empty' => 'यहाँ सबैभन्दा हालसालै सिर्जना गरिएका पुस्तकहरू देखा पर्नेछन्।',
+    'books_create' => 'नयाँ पुस्तक सिर्जना गर्नुहोस्',
+    'books_delete' => 'पुस्तक मेट्नुहोस्',
+    'books_delete_named' => 'पुस्तक मेट्नुहोस् :bookName',
+    'books_delete_explain' => 'यो पुस्तकलाई नाम \':bookName\' मेट्नेछ। सबै पृष्ठहरू र अध्यायहरू हटाइनेछन्।',
+    'books_delete_confirmation' => 'के तपाईं पक्का हुनुहुन्छ कि तपाईं यस पुस्तकलाई मेट्न चाहनुहुन्छ?',
+    'books_edit' => 'पुस्तक सम्पादन गर्नुहोस्',
+    'books_edit_named' => 'पुस्तक सम्पादन गर्नुहोस् :bookName',
+    'books_form_book_name' => 'पुस्तकको नाम',
+    'books_save' => 'पुस्तक बचत गर्नुहोस्',
+    'books_permissions' => 'पुस्तक अनुमतिहरू',
+    'books_permissions_updated' => 'पुस्तक अनुमतिहरू अद्यावधिक गरियो',
+    'books_empty_contents' => 'यस पुस्तकको लागि कुनै पृष्ठहरू वा अध्यायहरू सिर्जना गरिएका छैनन्।',
+    'books_empty_create_page' => 'नयाँ पृष्ठ सिर्जना गर्नुहोस्',
+    'books_empty_sort_current_book' => 'हालको पुस्तकलाई वर्गीकृत गर्नुहोस्',
+    'books_empty_add_chapter' => 'अध्याय थप्नुहोस्',
+    'books_permissions_active' => 'पुस्तक अनुमतिहरू सक्रिय छन्',
+    'books_search_this' => 'यस पुस्तकमा खोजी गर्नुहोस्',
+    'books_navigation' => 'पुस्तक नेभिगेशन',
+    'books_sort' => 'पुस्तक सामग्रीहरू वर्गीकृत गर्नुहोस्',
+    'books_sort_desc' => 'पुस्तकमा अध्यायहरू र पृष्ठहरूलाई पुनः व्यवस्थित गर्नका लागि सार्नुहोस्। अन्य पुस्तकहरू थप्न सकिन्छ जसले अध्याय र पृष्ठहरूलाई पुस्तकहरू बीच सजिलै सर्न मद्दत गर्दछ। वैकल्पिक रूपमा एक स्वचालित वर्गीकरण नियम सेट गर्न सकिन्छ जसले पुस्तकको सामग्रीहरू परिवर्तन भएपछि स्वत: वर्गीकृत गर्छ।',
+    'books_sort_auto_sort' => 'स्वचालित वर्गीकरण विकल्प',
+    'books_sort_auto_sort_active' => 'स्वचालित वर्गीकरण सक्रिय: :sortName',
+    'books_sort_named' => 'पुस्तक :bookName को वर्गीकरण गर्नुहोस्',
+    'books_sort_name' => 'नाम अनुसार वर्गीकृत गर्नुहोस्',
+    'books_sort_created' => 'सिर्जना मितिअनुसार वर्गीकृत गर्नुहोस्',
+    'books_sort_updated' => 'अद्यावधिक मितिअनुसार वर्गीकृत गर्नुहोस्',
+    'books_sort_chapters_first' => 'पहिले अध्यायहरू',
+    'books_sort_chapters_last' => 'अन्तिममा अध्यायहरू',
+    'books_sort_show_other' => 'अन्य पुस्तकहरू देखाउनुहोस्',
+    'books_sort_save' => 'नयाँ क्रम बचत गर्नुहोस्',
+    'books_sort_show_other_desc' => 'यहाँ अन्य पुस्तकहरू थप्नुहोस् जसले वर्गीकरण प्रक्रिया समावेश गर्न र पुस्तकहरू बीच सामग्री सजिलै पुनः व्यवस्थित गर्न मद्दत पुर्याउँछ।',
+    'books_sort_move_up' => 'माथि सार्नुहोस्',
+    'books_sort_move_down' => 'तल सार्नुहोस्',
+    'books_sort_move_prev_book' => 'अघिल्लो पुस्तकमा सार्नुहोस्',
+    'books_sort_move_next_book' => 'अर्को पुस्तकमा सार्नुहोस्',
+    'books_sort_move_prev_chapter' => 'अघिल्लो अध्यायमा सार्नुहोस्',
+    'books_sort_move_next_chapter' => 'अर्को अध्यायमा सार्नुहोस्',
+    'books_sort_move_book_start' => 'पुस्तकको सुरुवातमा सार्नुहोस्',
+    'books_sort_move_book_end' => 'पुस्तकको अन्त्यमा सार्नुहोस्',
+    'books_sort_move_before_chapter' => 'अध्यानको अघि सार्नुहोस्',
+    'books_sort_move_after_chapter' => 'अध्यानको पछि सार्नुहोस्',
+    'books_copy' => 'पुस्तक प्रतिलिपि गर्नुहोस्',
+    'books_copy_success' => 'पुस्तक सफलतापूर्वक प्रतिलिपि गरियो',
+
+    // Chapters
+    'chapter' => 'अध्याय',
+    'chapters' => 'अध्यायहरू',
+    'x_chapters' => ':count अध्याय|:count अध्यायहरू',
+    'chapters_popular' => 'लोकप्रिय अध्यायहरू',
+    'chapters_new' => 'नयाँ अध्याय',
+    'chapters_create' => 'नयाँ अध्याय सिर्जना गर्नुहोस्',
+    'chapters_delete' => 'अध्याय मेट्नुहोस्',
+    'chapters_delete_named' => 'अध्याय मेट्नुहोस् :chapterName',
+    'chapters_delete_explain' => 'यसले \':chapterName\' नामक अध्याय मेट्नेछ। यस अध्यायमा रहेका सबै पृष्ठहरू पनि मेटिनेछन्।',
+    'chapters_delete_confirm' => 'के तपाईं यस अध्यायलाई मेट्न चाहनुहुन्छ?',
+    'chapters_edit' => 'अध्याय सम्पादन गर्नुहोस्',
+    'chapters_edit_named' => 'अध्याय सम्पादन गर्नुहोस् :chapterName',
+    'chapters_save' => 'अध्याय बचत गर्नुहोस्',
+    'chapters_move' => 'अध्याय सार्नुहोस्',
+    'chapters_move_named' => 'अध्याय सार्नुहोस् :chapterName',
+    'chapters_copy' => 'अध्याय प्रतिलिपि गर्नुहोस्',
+    'chapters_copy_success' => 'अध्याय सफलतापूर्वक प्रतिलिपि गरिएको',
+    'chapters_permissions' => 'अध्याय अनुमतिहरू',
+    'chapters_empty' => 'हाल यस अध्यायमा कुनै पृष्ठहरू छैनन्।',
+    'chapters_permissions_active' => 'अध्याय अनुमतिहरू सक्रिय छन्',
+    'chapters_permissions_success' => 'अध्याय अनुमतिहरू अद्यावधिक गरिएका',
+    'chapters_search_this' => 'यस अध्यायको खोजी गर्नुहोस्',
+    'chapter_sort_book' => 'पुस्तक सॉर्ट गर्नुहोस्',
+
+    // Pages
+    'page' => 'पाना',
+    'pages' => 'पानाहरू',
+    'x_pages' => ':count पाना|:count पानाहरू',
+    'pages_popular' => 'लोकप्रिय पानाहरू',
+    'pages_new' => 'नयाँ पाना',
+    'pages_attachments' => 'जोडिएका फाइलहरू',
+    'pages_navigation' => 'पाना नेविगेसन',
+    'pages_delete' => 'पाना मेट्नुहोस्',
+    'pages_delete_named' => 'पाना मेट्नुहोस् :pageName',
+    'pages_delete_draft_named' => 'मस्यौदा पाना मेट्नुहोस् :pageName',
+    'pages_delete_draft' => 'मस्यौदा पाना मेट्नुहोस्',
+    'pages_delete_success' => 'पाना मेटियो',
+    'pages_delete_draft_success' => 'मस्यौदा पाना मेटियो',
+    'pages_delete_warning_template' => 'यो पाना पुस्तक वा अध्यायको डिफल्ट पृष्ठ ढाँचाको रूपमा सक्रिय छ। यो पाना मेटिएपछि, ती पुस्तक वा अध्यायहरूमा डिफल्ट पृष्ठ ढाँचाको असाइनमेन्ट हट्नेछ।',
+    'pages_delete_confirm' => 'के तपाईं यस पानालाई मेट्न चाहनुहुन्छ?',
+    'pages_delete_draft_confirm' => 'के तपाईं मस्यौदा पानालाई मेट्न चाहनुहुन्छ?',
+    'pages_editing_named' => 'पाना सम्पादन गर्दै :pageName',
+    'pages_edit_draft_options' => 'मस्यौदा विकल्पहरू',
+    'pages_edit_save_draft' => 'मस्यौदा बचत गर्नुहोस्',
+    'pages_edit_draft' => 'पाना मस्यौदा सम्पादन गर्नुहोस्',
+    'pages_editing_draft' => 'मस्यौदा सम्पादन गर्दै',
+    'pages_editing_page' => 'पाना सम्पादन गर्दै',
+    'pages_edit_draft_save_at' => 'मस्यौदा :time मा बचत गरिएको',
+    'pages_edit_delete_draft' => 'मस्यौदा मेट्नुहोस्',
+    'pages_edit_delete_draft_confirm' => 'के तपाईं आफ्नो मस्यौदा परिवर्तनहरू मेट्न चाहनुहुन्छ? सबै परिवर्तनहरू, अन्तिम पूर्ण बचतको पछि, हराउनेछन्।',
+    'pages_edit_discard_draft' => 'मस्यौदा त्याग्नुहोस्',
+    'pages_edit_switch_to_markdown' => 'Markdown सम्पादकमा स्विच गर्नुहोस्',
+    'pages_edit_switch_to_markdown_clean' => '(साफ सामग्री)',
+    'pages_edit_switch_to_markdown_stable' => '(स्थिर सामग्री)',
+    'pages_edit_switch_to_wysiwyg' => 'WYSIWYG सम्पादकमा स्विच गर्नुहोस्',
+    'pages_edit_switch_to_new_wysiwyg' => 'नयाँ WYSIWYG मा स्विच गर्नुहोस्',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(बीटा परीक्षणमा)',
+    'pages_edit_set_changelog' => 'चेंजलग सेट गर्नुहोस्',
+    'pages_edit_enter_changelog_desc' => 'तपाईंले गरेका परिवर्तनहरूको छोटो विवरण लेख्नुहोस्',
+    'pages_edit_enter_changelog' => 'चेंजलग लेख्नुहोस्',
+    'pages_editor_switch_title' => 'संपादक स्विच गर्नुहोस्',
+    'pages_editor_switch_are_you_sure' => 'के तपाईं पक्का हुनुहुन्छ कि तपाईं यस पानाको सम्पादक परिवर्तन गर्न चाहनुहुन्छ?',
+    'pages_editor_switch_consider_following' => 'सम्पादक परिवर्तन गर्दा निम्न कुरा ध्यानमा राख्नुहोस्:',
+    'pages_editor_switch_consideration_a' => 'एकपटक बचत भएपछि, नयाँ सम्पादक विकल्प भविष्यका सम्पादकहरूमा प्रयोग हुनेछ, जसमा त्यस्ता सम्पादकहरू पनि समावेश छन् जुन आफूले सम्पादकको प्रकार परिवर्तन गर्न सक्षम छैनन्।',
+    'pages_editor_switch_consideration_b' => 'यसले केही परिस्थितिहरूमा विवरण र सिन्ट्याक्सको हानि हुन सक्छ।',
+    'pages_editor_switch_consideration_c' => 'ट्याग वा चेंजलग परिवर्तनहरू, अन्तिम बचत पछि, यो परिवर्तनमा कायम रहनेछैन।',
+    'pages_save' => 'पाना बचत गर्नुहोस्',
+    'pages_title' => 'पाना शीर्षक',
+    'pages_name' => 'पाना नाम',
+    'pages_md_editor' => 'संपादक',
+    'pages_md_preview' => 'पूर्वावलोकन',
+    'pages_md_insert_image' => 'छवि समावेश गर्नुहोस्',
+    'pages_md_insert_link' => 'संगठन लिंक समावेश गर्नुहोस्',
+    'pages_md_insert_drawing' => 'चित्र समावेश गर्नुहोस्',
+    'pages_md_show_preview' => 'पूर्वावलोकन देखाउनुहोस्',
+    'pages_md_sync_scroll' => 'पूर्वावलोकन स्क्रोल सिंक गर्नुहोस्',
+    'pages_md_plain_editor' => 'Plaintext editor',
+    'pages_drawing_unsaved' => 'असुरक्षित चित्र भेटियो',
+    'pages_drawing_unsaved_confirm' => 'अघिल्लो असफल चित्र बचत प्रयासबाट असुरक्षित चित्र डेटा भेटिएको छ। के तपाईं यस असुरक्षित चित्रलाई पुनर्स्थापना गरेर सम्पादन गर्न चाहनुहुन्छ?',
+    'pages_not_in_chapter' => 'पाना कुनै अध्यायमा छैन',
+    'pages_move' => 'पाना सार्नुहोस्',
+    'pages_copy' => 'पाना प्रतिलिपि गर्नुहोस्',
+    'pages_copy_desination' => 'प्रतिलिपि गन्तव्य',
+    'pages_copy_success' => 'पाना सफलतापूर्वक प्रतिलिपि गरियो',
+    'pages_permissions' => 'पाना अनुमतिहरू',
+    'pages_permissions_success' => 'पाना अनुमतिहरू अद्यावधिक गरिएका',
+    'pages_revision' => 'संशोधन',
+    'pages_revisions' => 'पाना संशोधनहरू',
+    'pages_revisions_desc' => 'तल सूचीबद्ध गरिएको छ यो पानाका सबै पुराना संशोधनहरू। तपाईं पुराना पृष्ठ संस्करणहरू फर्केर हेर्न, तुलना गर्न र पुनर्स्थापना गर्न सक्नुहुन्छ, यदि अनुमतिहरूले अनुमति दिएको छ भने। प्रणाली कन्फिगरेसनको आधारमा पुराना संशोधनहरू स्वचालित रूपमा मेटिने हुन सक्छ।',
+    'pages_revisions_named' => ':pageName का पाना संशोधनहरू',
+    'pages_revision_named' => ':pageName का पाना संशोधन',
+    'pages_revision_restored_from' => 'पुनर्स्थापित गरिएको #:id; :summary',
+    'pages_revisions_created_by' => 'द्वारा सिर्जना गरिएको',
+    'pages_revisions_date' => 'संशोधन मिति',
+    'pages_revisions_number' => '#',
+    'pages_revisions_sort_number' => 'संशोधन संख्या',
+    'pages_revisions_numbered' => 'संशोधन #:id',
+    'pages_revisions_numbered_changes' => 'संशोधन #:id परिवर्तनहरू',
+    'pages_revisions_editor' => 'संपादक प्रकार',
+    'pages_revisions_changelog' => 'चेंजलग',
+    'pages_revisions_changes' => 'परिवर्तनहरू',
+    'pages_revisions_current' => 'हालको संस्करण:',
+    'pages_revisions_preview' => 'पूर्वावलोकन गर्नुहोस्',
+    'pages_revisions_restore' => 'पुनर्स्थापित गर्नुहोस्',
+    'pages_revisions_none' => 'यस पानामा कुनै संशोधन छैन',
+    'pages_copy_link' => 'लिंक प्रतिलिपि गर्नुहोस्',
+    'pages_edit_content_link' => 'संपादकमा खण्डमा जानुहोस्',
+    'pages_pointer_enter_mode' => 'खण्ड चयन मोडमा जानुहोस्',
+    'pages_pointer_label' => 'पाना खण्ड विकल्पहरू',
+    'pages_pointer_permalink' => 'पाना खण्ड स्थायी लिंक',
+    'pages_pointer_include_tag' => 'पाना खण्ड समावेश ट्याग',
+    'pages_pointer_toggle_link' => 'स्थायी लिंक मोड, समावेश ट्याग देखाउनका लागि थिच्नुहोस्',
+    'pages_pointer_toggle_include' => 'समावेश ट्याग मोड, स्थायी लिंक देखाउनका लागि थिच्नुहोस्',
+    'pages_permissions_active' => 'पाना अनुमतिहरू सक्रिय छन्',
+    'pages_initial_revision' => 'प्रारम्भिक प्रकाशन',
+    'pages_references_update_revision' => 'आन्तरिक लिंकहरूको प्रणाली स्वचालित अद्यावधिक',
+    'pages_initial_name' => 'नयाँ पाना',
+    'pages_editing_draft_notification' => 'तपाईं हाल एक मस्यौदा सम्पादन गर्दै हुनुहुन्छ जुन अन्तिम पटक :timeDiff मा बचत गरिएको थियो।',
+    'pages_draft_edited_notification' => 'यो पाना त्यस समय पछि अद्यावधिक गरिएको छ। यस मस्यौदालाई त्याग्नु उचित हुनेछ।',
+    'pages_draft_page_changed_since_creation' => 'यो पाना मस्यौदा सिर्जना भएपछि अद्यावधिक गरिएको छ। यस मस्यौदालाई त्याग्नुपर्छ वा कुनै पाना परिवर्तनहरू मेटिन नदिनुहोस्।',
+    'pages_draft_edit_active' => [
+        'start_a' => ':count प्रयोगकर्ताले यस पानालाई सम्पादन सुरु गरेका छन्',
+        'start_b' => ':userName ले यस पानालाई सम्पादन सुरु गरेका छन्',
+        'time_a' => 'पृष्ठ अन्तिम पटक अद्यावधिक भएको समयदेखि',
+        'time_b' => ':minCount मिनेटहरूको भित्र',
+        'message' => ':start :time। कृपया एकअर्काका अपडेटहरू मेट्नुहोस्!',
+    ],
+    'pages_draft_discarded' => 'मस्यौदा त्यागियो! सम्पादक वर्तमान पाना सामग्रीसँग अद्यावधिक गरिएको छ',
+    'pages_draft_deleted' => 'मस्यौदा मेटियो! सम्पादक वर्तमान पाना सामग्रीसँग अद्यावधिक गरिएको छ',
+    'pages_specific' => 'विशिष्ट पाना',
+    'pages_is_template' => 'पाना ढांचा',
+
+    // Editor Sidebar
+    'toggle_sidebar' => 'साइडबार टगल गर्नुहोस्',
+    'page_tags' => 'पाना ट्यागहरू',
+    'chapter_tags' => 'अध्याय ट्यागहरू',
+    'book_tags' => 'पुस्तक ट्यागहरू',
+    'shelf_tags' => 'शेल्फ ट्यागहरू',
+    'tag' => 'ट्याग',
+    'tags' =>  'ट्यागहरू',
+    'tags_index_desc' => 'ट्यागहरू सिस्टममा सामग्रीसँग लचिलो प्रकारको वर्गीकरण लागू गर्न प्रयोग गर्न सकिन्छ। ट्यागहरूमा किम्बो मान र मान हो सक्छ, जसले वैकल्पिक हुन्छ। एकपटक लागू भए पछि, सामग्रीलाई ट्यागको नाम र मान प्रयोग गरेर खोजी गर्न सकिन्छ।',
+    'tag_name' =>  'ट्याग नाम',
+    'tag_value' => 'ट्याग मान (वैकल्पिक)',
+    'tags_explain' => "आफ्नो सामग्रीलाई राम्रोसँग वर्गीकरण गर्न केही ट्यागहरू थप्नुहोस्। \n तपाई थप संगठनको लागि ट्यागको मान पनि असाइन गर्न सक्नुहुन्छ।",
+    'tags_add' => 'अर्को ट्याग थप्नुहोस्',
+    'tags_remove' => 'यो ट्याग हटाउनुहोस्',
+    'tags_usages' => 'कुल ट्याग प्रयोगहरू',
+    'tags_assigned_pages' => 'पानामा असाइन गरिएको',
+    'tags_assigned_chapters' => 'अध्यायहरूमा असाइन गरिएको',
+    'tags_assigned_books' => 'पुस्तकहरूमा असाइन गरिएको',
+    'tags_assigned_shelves' => 'शेल्फहरूमा असाइन गरिएको',
+    'tags_x_unique_values' => ':count अनन्य मानहरू',
+    'tags_all_values' => 'सबै मानहरू',
+    'tags_view_tags' => 'ट्यागहरू हेर्नुहोस्',
+    'tags_view_existing_tags' => 'अस्तित्वमा रहेका ट्यागहरू हेर्नुहोस्',
+    'tags_list_empty_hint' => 'ट्यागहरू पृष्ठ सम्पादक साइडबार वा पुस्तक, अध्याय वा शेल्फको विवरण सम्पादन गर्दा असाइन गर्न सकिन्छ।',
+    'attachments' => 'जोडिएका फाइलहरू',
+    'attachments_explain' => 'केही फाइलहरू अपलोड गर्नुहोस् वा केही लिङ्कहरू जोड्नुहोस् जसलाई तपाईंको पानामा देखाउन चाहनुहुन्छ। यी पृष्ठ साइडबारमा देखिनेछन्।',
+    'attachments_explain_instant_save' => 'यहाँका परिवर्तनहरू तुरुन्तै बचत हुन्छन्।',
+    'attachments_upload' => 'फाइल अपलोड गर्नुहोस्',
+    'attachments_link' => 'लिङ्क जोड्नुहोस्',
+    'attachments_upload_drop' => 'वैकल्पिक रूपमा तपाईं यहाँ एक फाइल ड्र्याग र ड्रप गरेर अपलोड गर्न सक्नुहुन्छ।',
+    'attachments_set_link' => 'लिङ्क सेट गर्नुहोस्',
+    'attachments_delete' => 'के तपाईं यो जोडिएको फाइल मेट्न चाहनुहुन्छ?',
+    'attachments_dropzone' => 'यहाँ फाइलहरू ड्र्याग गर्न र अपलोड गर्नको लागि ड्रप गर्नुहोस्',
+    'attachments_no_files' => 'कुनै फाइलहरू अपलोड गरिएको छैन',
+    'attachments_explain_link' => 'यदि तपाईंले कुनै फाइल अपलोड नगरीकन लिङ्क जोड्न चाहनुहुन्छ भने, तपाईं यसलाई थप्न सक्नुहुन्छ। यो अर्को पृष्ठको लिङ्क वा क्लाउडमा राखिएको फाइलको लिङ्क हुन सक्छ।',
+    'attachments_link_name' => 'लिङ्कको नाम',
+    'attachment_link' => 'जोडिएको फाइल लिङ्क',
+    'attachments_link_url' => 'फाइलको लिङ्क',
+    'attachments_link_url_hint' => 'साइट वा फाइलको URL',
+    'attach' => 'जोड्नुहोस्',
+    'attachments_insert_link' => 'पृष्ठमा जोडिएको फाइलको लिङ्क थप्नुहोस्',
+    'attachments_edit_file' => 'फाइल सम्पादन गर्नुहोस्',
+    'attachments_edit_file_name' => 'फाइलको नाम',
+    'attachments_edit_drop_upload' => 'फाइलहरू ड्र्याग गर्नुहोस् वा यहाँ क्लिक गरेर अपलोड र ओभरराइट गर्नुहोस्',
+    'attachments_order_updated' => 'जोडिएको फाइलहरूको क्रम अद्यावधिक गरियो',
+    'attachments_updated_success' => 'जोडिएको फाइलको विवरण अद्यावधिक गरियो',
+    'attachments_deleted' => 'जोडिएको फाइल मेटियो',
+    'attachments_file_uploaded' => 'फाइल सफलतापूर्वक अपलोड गरिएको',
+    'attachments_file_updated' => 'फाइल सफलतापूर्वक अद्यावधिक गरिएको',
+    'attachments_link_attached' => 'लिङ्क सफलतापूर्वक पृष्ठसँग जोडिएको',
+    'templates' => 'ढाँचाहरू',
+    'templates_set_as_template' => 'पाना ढाँचाको रूपमा सेट गर्नुहोस्',
+    'templates_explain_set_as_template' => 'तपाईं यस पानालाई ढाँचाको रूपमा सेट गर्न सक्नुहुन्छ ताकि यसको सामग्रीलाई अन्य पानाहरू सिर्जना गर्दा प्रयोग गर्न सकिन्छ। अन्य प्रयोगकर्ताहरू यस ढाँचालाई तब मात्र प्रयोग गर्न सक्छन् जब उनीहरूलाई यस पानाको भ्यू अनुमति छ।',
+    'templates_replace_content' => 'पाना सामग्री प्रतिस्थापन गर्नुहोस्',
+    'templates_append_content' => 'पाना सामग्रीमा थप्नुहोस्',
+    'templates_prepend_content' => 'पाना सामग्री अगाडि थप्नुहोस्',
+
+    // Profile View
+    'profile_user_for_x' => ':time का लागि प्रयोगकर्ता',
+    'profile_created_content' => 'सिर्जना गरिएको सामग्री',
+    'profile_not_created_pages' => ':userName ले कुनै पानाहरू सिर्जना गरेका छैनन्',
+    'profile_not_created_chapters' => ':userName ले कुनै अध्यायहरू सिर्जना गरेका छैनन्',
+    'profile_not_created_books' => ':userName ले कुनै पुस्तकहरू सिर्जना गरेका छैनन्',
+    'profile_not_created_shelves' => ':userName ले कुनै शेल्फहरू सिर्जना गरेका छैनन्',
+
+    // Comments
+    'comment' => 'टिप्पणी',
+    'comments' => 'टिप्पणीहरू',
+    'comment_add' => 'टिप्पणी थप्नुहोस्',
+    'comment_none' => 'प्रदर्शन गर्न कुनै टिप्पणी छैन',
+    'comment_placeholder' => 'यहाँ टिप्पणी छोड्नुहोस्',
+    'comment_thread_count' => 'टिप्पणीहरू',
+    'comment_archived_count' => ':count पुरानो',
+    'comment_archived_threads' => 'पुरानो थ्रेडहरू',
+    'comment_save' => 'टिप्पणी सेभ गर्नुहोस्',
+    'comment_new' => 'नयाँ टिप्पणी',
+    'comment_created' => ':createDiff मा टिप्पणी गरियो',
+    'comment_updated' => ':updateDiff मा :username द्वारा अद्यावधिक गरिएको',
+    'comment_updated_indicator' => 'अद्यावधिक गरिएको',
+    'comment_deleted_success' => 'टिप्पणी मेटियो',
+    'comment_created_success' => 'टिप्पणी थपियो',
+    'comment_updated_success' => 'टिप्पणी अद्यावधिक गरियो',
+    'comment_archive_success' => 'टिप्पणी पुरानो गरियो',
+    'comment_unarchive_success' => 'टिप्पणी पुनः सक्रिय गरियो',
+    'comment_view' => 'टिप्पणी हेर्नुहोस्',
+    'comment_jump_to_thread' => 'थ्रेडमा जानुहोस्',
+    'comment_delete_confirm' => 'के तपाईं यस टिप्पणीलाई मेट्न चाहनुहुन्छ?',
+    'comment_in_reply_to' => ':commentId को जवाफमा',
+    'comment_reference' => 'सन्दर्भ',
+    'comment_reference_outdated' => '(अप्रचलित)',
+    'comment_editor_explain' => 'यहाँ पृष्ठमा छोडिएका टिप्पणीहरू छन्। बचत गरिएको पृष्ठ हेरिरहँदा टिप्पणीहरू थप्न र व्यवस्थापन गर्न सकिन्छ।',
+
+    // Revision
+    'revision_delete_confirm' => 'के तपाईं यस संशोधनलाई मेट्न चाहनुहुन्छ?',
+    'revision_restore_confirm' => 'के तपाईं यस संशोधनलाई पुनर्स्थापित गर्न चाहनुहुन्छ? हालको पृष्ठ सामग्री प्रतिस्थापित हुनेछ।',
+    'revision_cannot_delete_latest' => 'अन्तिम संशोधन मेट्न सकिदैन।',
+
+    // Copy view
+    'copy_consider' => 'कृपया सामग्री प्रतिलिपि गर्दा तलका कुराहरू विचार गर्नुहोस्।',
+    'copy_consider_permissions' => 'कस्टम अनुमति सेटिङहरू प्रतिलिपि गरिने छैन।',
+    'copy_consider_owner' => 'तपाईं सबै प्रतिलिपि गरिएका सामग्रीका मालिक बन्नुहुनेछ।',
+    'copy_consider_images' => 'पृष्ठ चित्र फाइलहरू नक्कल गरिने छैनन् र मौलिक चित्रहरूले ती पृष्ठसँगको सम्बन्ध कायम राख्नेछन् जहाँ तिनीहरू पहिले अपलोड गरिएको थिए।',
+    'copy_consider_attachments' => 'पृष्ठ जडानहरू प्रतिलिपि गरिने छैनन्।',
+    'copy_consider_access' => 'स्थान, मालिक वा अनुमतिहरूको परिवर्तनले यस सामग्रीलाई पहिले पहुँच नभएका प्रयोगकर्ताहरूलाई उपलब्ध गराउन सक्छ।',
+
+    // Conversions
+    'convert_to_shelf' => 'शेल्फमा रूपान्तरण गर्नुहोस्',
+    'convert_to_shelf_contents_desc' => 'तपाईं यस पुस्तकलाई समान सामग्रीसँग नयाँ शेल्फमा रूपान्तरण गर्न सक्नुहुन्छ। यस पुस्तकमा भएका अध्यायहरू नयाँ पुस्तकहरूमा रूपान्तरण गरिनेछन्। यदि यस पुस्तकमा कुनै पृष्ठहरू छन् जुन कुनै अध्यायमा छैनन् भने, यस पुस्तकको नाम परिवर्तन गरिनेछ र ती पृष्ठहरू समावेश गरिनेछन्, र यस पुस्तकलाई नयाँ शेल्फको हिस्सा बनाइनेछ।',
+    'convert_to_shelf_permissions_desc' => 'यस पुस्तकमा सेट गरिएका कुनै पनि अनुमतिहरू नयाँ शेल्फ र सबै नयाँ बाल पुस्तकहरूमा प्रतिलिपि गरिनेछन् जुन आफ्ना अनुमतिहरू लागू गरेका छैनन्। ध्यान दिनुहोस् कि शेल्फहरूमा अनुमतिहरू स्वत: सामग्रीमा लागू हुँदैनन्, जस्तै पुस्तकहरूमा।',
+    'convert_book' => 'पुस्तक रूपान्तरण गर्नुहोस्',
+    'convert_book_confirm' => 'के तपाईं पक्का हुनुहुन्छ कि तपाईं यस पुस्तकलाई रूपान्तरण गर्न चाहनुहुन्छ?',
+    'convert_undo_warning' => 'यो सजिलै उल्टाउन सकिँदैन।',
+    'convert_to_book' => 'पुस्तकमा रूपान्तरण गर्नुहोस्',
+    'convert_to_book_desc' => 'तपाईं यस अध्यायलाई समान सामग्रीसँग नयाँ पुस्तकमा रूपान्तरण गर्न सक्नुहुन्छ। यस अध्यायमा सेट गरिएका कुनै पनि अनुमतिहरू नयाँ पुस्तकमा प्रतिलिपि गरिनेछन्, तर कुनै पनि पितृ पुस्तकबाट परिग्रहीत अनुमतिहरू प्रतिलिपि गरिने छैनन्, जसले पहुँच नियन्त्रणमा परिवर्तन ल्याउन सक्छ।',
+    'convert_chapter' => 'अध्याय रूपान्तरण गर्नुहोस्',
+    'convert_chapter_confirm' => 'के तपाईं पक्का हुनुहुन्छ कि तपाईं यस अध्यायलाई रूपान्तरण गर्न चाहनुहुन्छ?',
+
+    // References
+    'references' => 'सन्दर्भहरू',
+    'references_none' => 'यस वस्तुमा कुनै ट्र्याक गरिएको सन्दर्भहरू छैनन्।',
+    'references_to_desc' => 'तल सूचीबद्ध गरिएको छ सबै जानिएको सामग्री प्रणालीमा जुन यस वस्तुसँग लिंक गरिएको छ।',
+
+    // Watch Options
+    'watch' => 'हेर्नुहोस्',
+    'watch_title_default' => 'पूर्वनिर्धारित प्राथमिकताहरू',
+    'watch_desc_default' => 'हेर्नुहोस् केवल तपाईंका पूर्वनिर्धारित सूचनाको प्राथमिकताहरूमा फर्कनुहोस्।',
+    'watch_title_ignore' => 'बेवास्ता गर्नुहोस्',
+    'watch_desc_ignore' => 'सभी सूचनाहरू बेवास्ता गर्नुहोस्, प्रयोगकर्ता-स्तरका प्राथमिकताहरू सहित।',
+    'watch_title_new' => 'नयाँ पृष्ठहरू',
+    'watch_desc_new' => 'जब यस वस्तुमा कुनै नयाँ पृष्ठ सिर्जना गरिन्छ भने सूचित गर्नुहोस्।',
+    'watch_title_updates' => 'सभी पृष्ठ अपडेटहरू',
+    'watch_desc_updates' => 'सभी नयाँ पृष्ठ र पृष्ठ परिवर्तनहरूमा सूचित गर्नुहोस्।',
+    'watch_desc_updates_page' => 'सभी पृष्ठ परिवर्तनहरूमा सूचित गर्नुहोस्।',
+    'watch_title_comments' => 'सभी पृष्ठ अपडेटहरू र टिप्पणियाँ',
+    'watch_desc_comments' => 'सभी नयाँ पृष्ठहरू, पृष्ठ परिवर्तनहरू र नयाँ टिप्पणीहरूमा सूचित गर्नुहोस्।',
+    'watch_desc_comments_page' => 'पृष्ठ परिवर्तनहरू र नयाँ टिप्पणीहरूमा सूचित गर्नुहोस्।',
+    'watch_change_default' => 'पूर्वनिर्धारित सूचनाका प्राथमिकताहरू परिवर्तन गर्नुहोस्',
+    'watch_detail_ignore' => 'सूचनाहरू बेवास्ता गर्दै',
+    'watch_detail_new' => 'नयाँ पृष्ठहरू हेर्नुहोस्',
+    'watch_detail_updates' => 'नयाँ पृष्ठहरू र अपडेटहरू हेर्नुहोस्',
+    'watch_detail_comments' => 'नयाँ पृष्ठहरू, अपडेटहरू र टिप्पणीहरू हेर्नुहोस्',
+    'watch_detail_parent_book' => 'पितृ पुस्तक मार्फत हेर्नुहोस्',
+    'watch_detail_parent_book_ignore' => 'पितृ पुस्तक मार्फत बेवास्ता गर्दै',
+    'watch_detail_parent_chapter' => 'पितृ अध्याय मार्फत हेर्नुहोस्',
+    'watch_detail_parent_chapter_ignore' => 'पितृ अध्याय मार्फत बेवास्ता गर्दै',
+];
diff --git a/lang/ne/errors.php b/lang/ne/errors.php
new file mode 100644
index 000000000..79ee27c78
--- /dev/null
+++ b/lang/ne/errors.php
@@ -0,0 +1,133 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'तपाईंले अनुरोध गरिएको पाना पहुँच गर्नको लागि अनुमति प्राप्त गर्नुभएको छैन।',
+    'permissionJson' => 'तपाईंले अनुरोध गरिएको क्रियाकलाप गर्नको लागि अनुमति प्राप्त गर्नुभएको छैन।',
+
+    // Auth
+    'error_user_exists_different_creds' => 'इमेल :email संग पहिले नै प्रयोगकर्ता अस्तित्वमा छ तर फरक प्रमाणपत्रहरूका साथ।',
+    'auth_pre_register_theme_prevention' => 'प्रदत्त विवरणका लागि प्रयोगकर्ता खाता दर्ता गर्न सकिएन।',
+    'email_already_confirmed' => 'इमेल पहिले नै प्रमाणित भइसकेको छ, कृपया लगइन प्रयास गर्नुहोस्।',
+    'email_confirmation_invalid' => 'यो पुष्टि टोकन अवैध छ वा पहिले नै प्रयोग भइसकेको छ, कृपया पुनः दर्ता प्रयास गर्नुहोस्।',
+    'email_confirmation_expired' => 'पुष्टि टोकन समाप्त भइसकेको छ, नयाँ पुष्टि इमेल पठाइएको छ।',
+    'email_confirmation_awaiting' => 'यो खाताको इमेल ठेगाना प्रमाणित गर्न बाँकी छ।',
+    'ldap_fail_anonymous' => 'LDAP पहुँच अज्ञात बाइन्ड प्रयोग गरेर असफल भएको छ।',
+    'ldap_fail_authed' => 'LDAP पहुँच निर्दिष्ट dn र पासवर्ड विवरण प्रयोग गरेर असफल भएको छ।',
+    'ldap_extension_not_installed' => 'LDAP PHP एक्स्टेन्सन इन्स्टल गरिएको छैन।',
+    'ldap_cannot_connect' => 'LDAP सर्भरमा जडान गर्न सकिएन, आरम्भिक जडान असफल भएको छ।',
+    'saml_already_logged_in' => 'पहिले नै लगइन हुनुहुन्छ।',
+    'saml_no_email_address' => 'बाह्य प्रमाणीकरण प्रणालीले प्रदान गरेको डाटामा यस प्रयोगकर्ताको इमेल ठेगाना भेट्न सकिएन।',
+    'saml_invalid_response_id' => 'बाह्य प्रमाणीकरण प्रणालीबाट आएको अनुरोध यस एप्लिकेशनद्वारा सुरु गरिएको प्रक्रिया द्वारा मान्यता प्राप्त छैन। लगइन पछि फर्किने प्रयास गर्दा यो समस्या उत्पन्न हुन सक्छ।',
+    'saml_fail_authed' => ':system प्रयोग गरेर लगइन असफल भएको छ, प्रणालीले सफल प्रमाणिकरण प्रदान गरेको छैन।',
+    'oidc_already_logged_in' => 'पहिले नै लगइन हुनुहुन्छ।',
+    'oidc_no_email_address' => 'बाह्य प्रमाणीकरण प्रणालीले प्रदान गरेको डाटामा यस प्रयोगकर्ताको इमेल ठेगाना भेट्न सकिएन।',
+    'oidc_fail_authed' => ':system प्रयोग गरेर लगइन असफल भएको छ, प्रणालीले सफल प्रमाणिकरण प्रदान गरेको छैन।',
+    'social_no_action_defined' => 'कोई क्रियाकलाप परिभाषित गरिएको छैन।',
+    'social_login_bad_response' => ":socialAccount लगइनको समयमा त्रुटि प्राप्त: \n:error",
+    'social_account_in_use' => 'यो :socialAccount खाता पहिले नै प्रयोगमा छ, कृपया :socialAccount विकल्प मार्फत लगइन प्रयास गर्नुहोस्।',
+    'social_account_email_in_use' => 'इमेल :email पहिले नै प्रयोगमा छ। यदि तपाईंको खाता छ भने, तपाईं आफ्नो प्रोफाइल सेटिङमा :socialAccount खाता जडान गर्न सक्नुहुन्छ।',
+    'social_account_existing' => 'यो :socialAccount तपाईंको प्रोफाइलसँग पहिले नै जडान गरिएको छ।',
+    'social_account_already_used_existing' => 'यो :socialAccount खाता पहिले नै अर्को प्रयोगकर्ताद्वारा प्रयोगमा छ।',
+    'social_account_not_used' => 'यो :socialAccount खाता कुनै प्रयोगकर्तासँग जडान गरिएको छैन। कृपया यसलाई आफ्नो प्रोफाइल सेटिङमा जडान गर्नुहोस्।',
+    'social_account_register_instructions' => 'यदि तपाईंको खाता छैन भने, तपाईं :socialAccount विकल्प प्रयोग गरेर खाता दर्ता गर्न सक्नुहुन्छ।',
+    'social_driver_not_found' => 'सामाजिक ड्राइभर फेला पारिएको छैन।',
+    'social_driver_not_configured' => 'तपाईंको :socialAccount सामाजिक सेटिङ सही तरिकाले कन्फिगर गरिएको छैन।',
+    'invite_token_expired' => 'यो निमन्त्रणा लिंक समाप्त भइसकेको छ। तपाईं सट्टा आफ्नो खाता पासवर्ड रिसेट गर्न प्रयास गर्न सक्नुहुन्छ।',
+    'login_user_not_found' => 'यो क्रियाकलापका लागि प्रयोगकर्ता फेला पारिएको छैन।',
+
+    // System
+    'path_not_writable' => 'फाइल पथ :filePath मा अपलोड गर्न सकिएन। कृपया यो पथ सर्भरमा लेख्न योग्य बनाउन सुनिश्चित गर्नुहोस्।',
+    'cannot_get_image_from_url' => ':url बाट चित्र प्राप्त गर्न सकिएन।',
+    'cannot_create_thumbs' => 'सर्भरले थम्बनेल बनाउन सक्दैन। कृपया तपाईंको सिस्टममा GD PHP एक्स्टेन्सन इन्स्टल गरिएको छ भनेर जाँच गर्नुहोस्।',
+    'server_upload_limit' => 'सर्भरले यस आकारको अपलोड अनुमति दिंदैन। कृपया सानो फाइल आकारको प्रयास गर्नुहोस्।',
+    'server_post_limit' => 'सर्भरले दिएको डेटा आकार प्राप्त गर्न सक्दैन। कृपया कम डेटा वा सानो फाइलको प्रयास गर्नुहोस्।',
+    'uploaded'  => 'सर्भरले यस आकारको अपलोड अनुमति दिंदैन। कृपया सानो फाइल आकारको प्रयास गर्नुहोस्।',
+
+    // Drawing & Images
+    'image_upload_error' => 'चित्र अपलोड गर्दा त्रुटि भयो।',
+    'image_upload_type_error' => 'अपलोड गरिएको चित्र प्रकार अवैध छ।',
+    'image_upload_replace_type' => 'चित्र फाइल प्रतिस्थापनहरू समान प्रकारका हुनुपर्छ।',
+    'image_upload_memory_limit' => 'चित्र अपलोड गर्न र/वा थम्बनेल बनाउन असफल भएको छ, यो प्रणाली संसाधन सीमाहरूको कारणले हो।',
+    'image_thumbnail_memory_limit' => 'चित्रको आकार भिन्नताहरू बनाउन असफल भएको छ, यो प्रणाली संसाधन सीमाहरूको कारणले हो।',
+    'image_gallery_thumbnail_memory_limit' => 'ग्यालरी थम्बनेल बनाउन असफल भएको छ, यो प्रणाली संसाधन सीमाहरूको कारणले हो।',
+    'drawing_data_not_found' => 'चित्रको डाटा लोड गर्न सकिएन। चित्र फाइल अब अस्तित्वमा नभएको हुन सक्छ वा तपाईंलाई यसमा पहुँचको अनुमति नहुन सक्छ।',
+
+    // Attachments
+    'attachment_not_found' => 'जोडिएको फाइल फेला परेन।',
+    'attachment_upload_error' => 'जोडिएको फाइल अपलोड गर्दा त्रुटि भयो।',
+
+    // Pages
+    'page_draft_autosave_fail' => 'ड्राफ्ट बचत गर्न असफल भयो। यो पाना बचत गर्नु अघि कृपया इन्टरनेट जडान सुनिश्चित गर्नुहोस्।',
+    'page_draft_delete_fail' => 'पाना ड्राफ्ट मेटाउन र वर्तमान पाना सामग्री ल्याउन असफल भयो।',
+    'page_custom_home_deletion' => 'एक पाना लाई होमपाना को रूपमा सेट गर्दा मेटाउन सकिँदैन।',
+
+    // Entities
+    'entity_not_found' => 'इकाई फेला परेन।',
+    'bookshelf_not_found' => 'शेल्फ फेला परेन।',
+    'book_not_found' => 'पुस्तक फेला परेन।',
+    'page_not_found' => 'पाना फेला परेन।',
+    'chapter_not_found' => 'अध्याय फेला परेन।',
+    'selected_book_not_found' => 'चयन गरिएको पुस्तक फेला परेन।',
+    'selected_book_chapter_not_found' => 'चयन गरिएको पुस्तक वा अध्याय फेला परेन।',
+    'guests_cannot_save_drafts' => 'अतिथिहरू ड्राफ्टहरू बचत गर्न सक्दैनन्।',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'तपाईं केवल व्यवस्थापक भएको प्रयोगकर्तालाई मेटाउन सक्दैनौं।',
+    'users_cannot_delete_guest' => 'तपाईं अतिथि प्रयोगकर्तालाई मेटाउन सक्दैनौं।',
+    'users_could_not_send_invite' => 'प्रयोगकर्ता सिर्जना गर्न सकिएन, निमन्त्रणा इमेल पठाउन असफल भयो।',
+
+    // Roles
+    'role_cannot_be_edited' => 'यो भूमिका सम्पादन गर्न सकिँदैन।',
+    'role_system_cannot_be_deleted' => 'यो भूमिका एक प्रणाली भूमिका हो र मेटाउन सकिँदैन।',
+    'role_registration_default_cannot_delete' => 'यो भूमिका दर्ता गरेको डिफल्ट भूमिका भएकोले मेटाउन सकिँदैन।',
+    'role_cannot_remove_only_admin' => 'यो प्रयोगकर्ता व्यवस्थापक भूमिकामा मात्र एकमात्र प्रयोगकर्ता हो। यसलाई हटाउन प्रयास गर्नु अघि अर्को प्रयोगकर्तालाई व्यवस्थापक भूमिका दिनुहोस्।',
+
+    // Comments
+    'comment_list' => 'टिप्पणीहरू प्राप्त गर्दा त्रुटि भयो।',
+    'cannot_add_comment_to_draft' => 'तपाईं ड्राफ्टमा टिप्पणी थप्न सक्नुहुन्न।',
+    'comment_add' => 'टिप्पणी थप्दा / अद्यावधिक गर्दा त्रुटि भयो।',
+    'comment_delete' => 'टिप्पणी मेट्दा त्रुटि भयो।',
+    'empty_comment' => 'खाली टिप्पणी थप्न सकिँदैन।',
+
+    // Error pages
+    '404_page_not_found' => 'पाना फेला परेन।',
+    'sorry_page_not_found' => 'माफ गर्नुहोस्, तपाईंले खोज्नुभएको पाना फेला परेन।',
+    'sorry_page_not_found_permission_warning' => 'यदि तपाईंलाई यो पाना अस्तित्वमा हुनु पर्ने आशा थियो भने, तपाईंलाई यसलाई हेर्न अनुमति नहुन सक्छ।',
+    'image_not_found' => 'चित्र फेला परेन।',
+    'image_not_found_subtitle' => 'माफ गर्नुहोस्, तपाईंले खोज्नुभएको चित्र फाइल फेला परेन।',
+    'image_not_found_details' => 'यदि तपाईंले यो चित्र फेला पार्नु पर्ने आशा राख्नु भएको थियो भने, यो मेटिएको हुन सक्छ।',
+    'return_home' => 'गृहपृष्ठमा फर्कनुहोस्',
+    'error_occurred' => 'एउटा त्रुटि भयो।',
+    'app_down' => ':appName अहिले डाउन छ।',
+    'back_soon' => 'यो चाँडै पुनः सक्रिय हुनेछ।',
+
+    // Import
+    'import_zip_cant_read' => 'ZIP फाइल पढ्न सकिएन।',
+    'import_zip_cant_decode_data' => 'ZIP डाटा.json सामग्री पत्ता लाग्न र डिकोड गर्न सकिएन।',
+    'import_zip_no_data' => 'ZIP फाइल डाटामा अपेक्षित पुस्तक, अध्याय वा पाना सामग्री छैन।',
+    'import_validation_failed' => 'आयात ZIP प्रमाणीकरण असफल भयो। त्रुटिहरू छन्:',
+    'import_zip_failed_notification' => 'ZIP फाइल आयात गर्न असफल भयो।',
+    'import_perms_books' => 'तपाईंलाई पुस्तकहरू सिर्जना गर्न आवश्यक अनुमति छैन।',
+    'import_perms_chapters' => 'तपाईंलाई अध्यायहरू सिर्जना गर्न आवश्यक अनुमति छैन।',
+    'import_perms_pages' => 'तपाईंलाई पाना सिर्जना गर्न आवश्यक अनुमति छैन।',
+    'import_perms_images' => 'तपाईंलाई चित्रहरू सिर्जना गर्न आवश्यक अनुमति छैन।',
+    'import_perms_attachments' => 'तपाईंलाई अनुलग्नकहरू सिर्जना गर्न आवश्यक अनुमति छैन।',
+
+    // API errors
+    'api_no_authorization_found' => 'अनुरोधमा कुनै प्रमाणीकरण टोकन फेला परेन।',
+    'api_bad_authorization_format' => 'अनुरोधमा प्रमाणीकरण टोकन फेला परे तापनि यसको ढाँचा गलत देखिन्छ।',
+    'api_user_token_not_found' => 'दिएको प्रमाणीकरण टोकनको लागि मिल्दो API टोकन फेला परेन।',
+    'api_incorrect_token_secret' => 'दिइएको API टोकनको लागि प्रदान गरिएको गोप्य सही छैन।',
+    'api_user_no_api_permission' => 'API टोकनको मालिकसँग API कल गर्ने अनुमति छैन।',
+    'api_user_token_expired' => 'प्रमाणीकरण टोकन समाप्त भइसकेको छ।',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'टेस्ट इमेल पठाउँदा त्रुटि:',
+
+    // HTTP errors
+    'http_ssr_url_no_match' => 'URL कन्फिगर गरिएका अनुमत SSR होस्टसँग मेल खाँदैन।',
+];
diff --git a/lang/ne/notifications.php b/lang/ne/notifications.php
new file mode 100644
index 000000000..1e9c57221
--- /dev/null
+++ b/lang/ne/notifications.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Text used for activity-based notifications.
+ */
+return [
+
+    'new_comment_subject' => 'नयाँ टिप्पणी: :pageName पानामा',
+    'new_comment_intro' => 'एक प्रयोगकर्ताले :appName मा रहेको पानामा टिप्पणी गरेका छन्:',
+    'new_page_subject' => 'नयाँ पाना: :pageName',
+    'new_page_intro' => ':appName मा नयाँ पाना बनाइएको छ',
+    'updated_page_subject' => 'पाना अपडेट भयो: :pageName',
+    'updated_page_intro' => ':appName मा पाना अपडेट गरिएको छ',
+    'updated_page_debounce' => 'धेरै सूचना नपरोस् भनेर, केही समयको लागि एउटै सम्पादकबाट हुने थप सम्पादनहरूका सूचना तपाईंलाई पठाइने छैन।',
+
+    'detail_page_name' => 'पानाको नाम:',
+    'detail_page_path' => 'पानाको स्थान:',
+    'detail_commenter' => 'टिप्पणी गर्ने:',
+    'detail_comment' => 'टिप्पणी:',
+    'detail_created_by' => 'बनाउने व्यक्ति:',
+    'detail_updated_by' => 'अपडेट गर्ने व्यक्ति:',
+
+    'action_view_comment' => 'टिप्पणी हेर्नुहोस्',
+    'action_view_page' => 'पाना हेर्नुहोस्',
+
+    'footer_reason' => 'तपाईंलाई यो सूचना :link अनुसार पठाइएको हो, जुन यस प्रकारको गतिविधिमा लागु हुन्छ।',
+    'footer_reason_link' => 'तपाईंको सूचना प्राथमिकता',
+];
diff --git a/lang/ne/pagination.php b/lang/ne/pagination.php
new file mode 100644
index 000000000..3da50cb08
--- /dev/null
+++ b/lang/ne/pagination.php
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Pagination Language Lines
+ * The following language lines are used by the paginator library to build
+ * the simple pagination links.
+ */
+return [
+
+    'previous' => '&laquo; अघिल्लो',
+    'next'     => 'अर्को &raquo;',
+
+];
diff --git a/lang/ne/passwords.php b/lang/ne/passwords.php
new file mode 100644
index 000000000..684201a8e
--- /dev/null
+++ b/lang/ne/passwords.php
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Password Reminder Language Lines
+ * The following language lines are the default lines which match reasons
+ * that are given by the password broker for a password update attempt has failed.
+ */
+return [
+
+    'password' => 'पासवर्ड कम्तिमा ८ वर्णको हुनु पर्छ र दाेहाेर्याइएकाे पासवर्ड संग मेल खानु पर्छ।',
+    'user' => "हामीले त्यो इमेल ठेगाना भएको प्रयोगकर्ता फेला पार्न सकेनौं।",
+    'token' => 'यस इमेल ठेगानाको लागि पासवर्ड रिसेट टोकन अमान्य छ।',
+    'sent' => 'हामीले तपाईंको पासवर्ड रिसेट लिङ्क इमेल गरेका छौं!',
+    'reset' => 'पासवर्ड रिसेट भयो!',
+
+];
diff --git a/lang/ne/preferences.php b/lang/ne/preferences.php
new file mode 100644
index 000000000..70388e8ba
--- /dev/null
+++ b/lang/ne/preferences.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * Text used for user-preference specific views within bookstack.
+ */
+
+return [
+    'my_account' => 'मेरो खाता',
+
+    'shortcuts' => 'सर्टकटहरू',
+    'shortcuts_interface' => 'UI सर्टकट प्राथमिकताहरू',
+    'shortcuts_toggle_desc' => 'यहाँ तपाईं किबोर्ड प्रणाली इन्टरफेस सर्टकटहरू सक्षम वा असक्षम गर्न सक्नुहुन्छ, जुन नेभिगेशन र क्रियाकलापहरूको लागि प्रयोग गरिन्छ।',
+    'shortcuts_customize_desc' => 'तपाईं तलका प्रत्येक सर्टकटलाई अनुकूलित गर्न सक्नुहुन्छ। केवल सर्टकटको इनपुट चयन गरेपछि आफ्नो इच्छित कीबोर्ड संयोजन थिच्नुहोस्।',
+    'shortcuts_toggle_label' => 'किबोर्ड सर्टकट सक्षम छ',
+    'shortcuts_section_navigation' => 'नेभिगेशन',
+    'shortcuts_section_actions' => 'साधारण क्रियाकलापहरू',
+    'shortcuts_save' => 'सर्टकटहरू बचत गर्नुहोस्',
+    'shortcuts_overlay_desc' => 'नोट: जब सर्टकटहरू सक्षम हुन्छन्, तब "?" थिचेर एक सहायक ओभरले देखाइन्छ जसले स्क्रीनमा हाल देखिएका क्रियाकलापहरूको लागि उपलब्ध सर्टकटहरू हाइलाइट गर्दछ।',
+    'shortcuts_update_success' => 'सर्टकट प्राथमिकताहरू अपडेट गरिएका छन्!',
+    'shortcuts_overview_desc' => 'प्रणाली प्रयोगकर्ता इन्टरफेसमा नेभिगेट गर्न तपाईंले प्रयोग गर्न सक्ने किबोर्ड सर्टकटहरू व्यवस्थापन गर्नुहोस्।',
+
+    'notifications' => 'सूचना प्राथमिकताहरू',
+    'notifications_desc' => 'प्रणालीमा केही क्रियाकलापहरू गर्दा तपाईंलाई प्राप्त हुने इमेल सूचनाहरू नियन्त्रण गर्नुहोस्।',
+    'notifications_opt_own_page_changes' => 'मैले स्वामित्व राख्ने पृष्ठहरूमा परिवर्तन हुँदा सूचित गर्नुहोस्',
+    'notifications_opt_own_page_comments' => 'मैले स्वामित्व राख्ने पृष्ठहरूमा टिप्पणी हुँदा सूचित गर्नुहोस्',
+    'notifications_opt_comment_replies' => 'मेरो टिप्पणीहरूमा उत्तर आएको बेला सूचित गर्नुहोस्',
+    'notifications_save' => 'प्राथमिकताहरू बचत गर्नुहोस्',
+    'notifications_update_success' => 'सूचना प्राथमिकताहरू अपडेट गरिएका छन्!',
+    'notifications_watched' => 'हेर्ने र बेवास्ता गरिएका सामग्रीहरू',
+    'notifications_watched_desc' => 'तल ती सामग्रीहरू छन् जसमा कस्टम वाच प्राथमिकताहरू लागू गरिएका छन्। यीलाई अपडेट गर्नको लागि सामग्री हेरेर, साइडबारमा वाच विकल्पहरू फेला पार्नुहोस्।',
+
+    'auth' => 'प्रवेश र सुरक्षा',
+    'auth_change_password' => 'पासवर्ड परिवर्तन गर्नुहोस्',
+    'auth_change_password_desc' => 'तपाईंको एप्लिकेसनमा लगइन गर्न प्रयोग गरिने पासवर्ड परिवर्तन गर्नुहोस्। यो कम्तिमा ८ अक्षर लामो हुनुपर्छ।',
+    'auth_change_password_success' => 'पासवर्ड अपडेट गरियो!',
+
+    'profile' => 'प्रोफाइल विवरण',
+    'profile_desc' => 'तपाईंको खाता विवरण व्यवस्थापन गर्नुहोस् जसले तपाईंलाई अन्य प्रयोगकर्ताहरूको लागि प्रतिनिधित्व गर्दछ, साथै सम्पर्क र प्रणाली अनुकूलनका लागि प्रयोग गरिने विवरणहरू।',
+    'profile_view_public' => 'सार्वजनिक प्रोफाइल हेर्नुहोस्',
+    'profile_name_desc' => 'तपाईंको प्रदर्शन नाम कन्फिगर गर्नुहोस् जुन प्रणालीमा अन्य प्रयोगकर्ताहरूलाई तपाईंको क्रियाकलाप र स्वामित्व भएको सामग्रीमार्फत देखिनेछ।',
+    'profile_email_desc' => 'यो इमेल सूचनाहरूको लागि प्रयोग हुनेछ र, सक्रिय प्रणाली प्रमाणिकरणमा निर्भर गर्दै, प्रणाली प्रवेशको लागि पनि प्रयोग हुनेछ।',
+    'profile_email_no_permission' => 'दुर्भाग्यवश तपाईंलाई तपाईंको इमेल ठेगाना परिवर्तन गर्ने अनुमति छैन। यदि तपाईं यसलाई परिवर्तन गर्न चाहनुहुन्छ भने, तपाईंलाई एक व्यवस्थापकलाई अनुरोध गर्नु पर्नेछ।',
+    'profile_avatar_desc' => 'तपाईंको प्रतिनिधित्व गर्नको लागि एक छवि चयन गर्नुहोस् जुन प्रणालीमा अन्य प्रयोगकर्ताहरूलाई तपाईंको रूपमा देखाउनेछ। यस छविको आकार वर्गाकार र लगभग २५६px चौडाइ र उचाइ भएको हुनु पर्छ।',
+    'profile_admin_options' => 'व्यवस्थापक विकल्पहरू',
+    'profile_admin_options_desc' => 'अधिकार व्यवस्थापन जस्ता अतिरिक्त व्यवस्थापक-स्तरका विकल्पहरू तपाईंको प्रयोगकर्ता खाता "सेटिंग्स > प्रयोगकर्ताहरू" क्षेत्रमा फेला पार्न सकिन्छ।',
+
+    'delete_account' => 'खाता मेटाउनुहोस्',
+    'delete_my_account' => 'मेरो खाता मेटाउनुहोस्',
+    'delete_my_account_desc' => 'यसले तपाईंको प्रयोगकर्ता खाता प्रणालीबाट पूर्ण रूपमा मेटाउनेछ। तपाईं यो खाता पुन: प्राप्त गर्न वा यो क्रियाकलापलाई फर्काउन सक्नुहुन्न। तपाईंले सिर्जना गरेको सामग्री, जस्तै सिर्जना गरिएका पृष्ठहरू र अपलोड गरिएका चित्रहरू, बाँकी रहनेछन्।',
+    'delete_my_account_warning' => 'के तपाईं यो खाता मेटाउन निश्चित हुनुहुन्छ?',
+];
diff --git a/lang/ne/settings.php b/lang/ne/settings.php
new file mode 100644
index 000000000..eb99f26bd
--- /dev/null
+++ b/lang/ne/settings.php
@@ -0,0 +1,370 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'सेटिङ्ग',
+    'settings_save' => 'सेटिङ्ग सुरक्षित गर्नुहोस्',
+    'system_version' => 'सिस्टम संस्करण',
+    'categories' => 'क्याटोगोरीहरु',
+
+    // App Settings
+    'app_customization' => 'अनुकूलन',
+    'app_features_security' => 'फिचरहरू र सुरक्षा',
+    'app_name' => 'एप्लिकेसन नाम',
+    'app_name_desc' => 'यो नाम हेडरमा र कुनै पनि प्रणालीले पठाएको इमेलमा देखाइनेछ।',
+    'app_name_header' => 'हेडरमा नाम देखाउनुहोस्',
+    'app_public_access' => 'सार्वजनिक पहुँच',
+    'app_public_access_desc' => 'यो विकल्प सक्षम गर्दा, लगइन नगरेका आगन्तुकहरूले तपाईंको BookStack मा सामग्री पहुँच गर्न सक्नेछन्।',
+    'app_public_access_desc_guest' => 'सार्वजनिक आगन्तुकहरूको पहुँच "Guest" प्रयोगकर्ताबाट नियन्त्रण गर्न सकिन्छ।',
+    'app_public_access_toggle' => 'सार्वजनिक पहुँच अनुमति दिनुहोस्',
+    'app_public_viewing' => 'सार्वजनिक हेर्न अनुमति दिनुहोस्?',
+    'app_secure_images' => 'उच्च सुरक्षा छवि अपलोडहरू',
+    'app_secure_images_toggle' => 'उच्च सुरक्षा छवि अपलोडहरू सक्षम गर्नुहोस्',
+    'app_secure_images_desc' => 'प्रदर्शन कारणहरूका लागि, सबै छविहरू सार्वजनिक हुन्छन्। यो विकल्पले छवि URL अगाडि एउटा अनियमित, अनुमान गर्न गाह्रो स्ट्रिङ थप्छ। सजिलो पहुँच रोक्न निर्देशिका सूचीकरण निष्क्रिय गर्नुहोस्।',
+    'app_default_editor' => 'डिफल्ट पृष्ठ सम्पादक',
+    'app_default_editor_desc' => 'नयाँ पृष्ठ सम्पादन गर्दा डिफल्ट रूपमा प्रयोग हुने सम्पादक चयन गर्नुहोस्। अनुमति अनुसार पृष्ठ स्तरमा यो परिवर्तन गर्न सकिन्छ।',
+    'app_custom_html' => 'कस्टम HTML हेड सामग्री',
+    'app_custom_html_desc' => 'यहाँ थपिएको कुनै पनि सामग्री प्रत्येक पृष्ठको <head> सेक्सनको तल्लो भागमा समावेश हुनेछ। स्टाइल ओभरराइड वा एनालिटिक्स कोड थप्न उपयोगी।',
+    'app_custom_html_disabled_notice' => 'कस्टम HTML हेड सामग्री यस सेटिङ पृष्ठमा असक्षम गरिएको छ ताकि कुनै समस्या भएमा फर्काउन सकियोस्।',
+    'app_logo' => 'एप्लिकेसन लोगो',
+    'app_logo_desc' => 'यो एप्लिकेसन हेडर बार लगायत अन्य ठाउँहरूमा प्रयोग हुन्छ। यो छवि ८६px उचाइको हुनु पर्नेछ। ठूलो छविहरू सानो गरिनेछ।',
+    'app_icon' => 'एप्लिकेसन आइकन',
+    'app_icon_desc' => 'यो आइकन ब्राउजर ट्याब र छोटोमार्ग आइकनहरूका लागि प्रयोग हुन्छ। PNG २५६px वर्गाकार छवि हुनुपर्छ।',
+    'app_homepage' => 'एप्लिकेसन होमपेज',
+    'app_homepage_desc' => 'डिफल्ट दृश्यको सट्टा होमपेजमा देखाउनको लागि कुनै दृश्य चयन गर्नुहोस्। चयन गरिएका पृष्ठहरूको अनुमति बेवास्ता गरिनेछ।',
+    'app_homepage_select' => 'पृष्ठ चयन गर्नुहोस्',
+    'app_footer_links' => 'फुटर लिंकहरू',
+    'app_footer_links_desc' => 'साइटको फुटरमा देखाउन लिंकहरू थप्नुहोस्। यी प्रायः पृष्ठहरूको तल्लो भागमा देखिनेछन्, जसमा लगइन आवश्यक नभएका पृष्ठहरू पनि समावेश छन्। "trans::<key>" ले प्रणाली-परिभाषित अनुवाद प्रयोग गर्न सकिन्छ। उदाहरण: "trans::common.privacy_policy" ले "गोपनीयता नीति" र "trans::common.terms_of_service" ले "सेवा सर्तहरू" देखाउनेछ।',
+    'app_footer_links_label' => 'लिंक लेबल',
+    'app_footer_links_url' => 'लिंक URL',
+    'app_footer_links_add' => 'फुटर लिंक थप्नुहोस्',
+    'app_disable_comments' => 'टिप्पणीहरू असक्षम पार्नुहोस्',
+    'app_disable_comments_toggle' => 'टिप्पणीहरू असक्षम पार्नुहोस्',
+    'app_disable_comments_desc' => 'एप्लिकेसनका सबै पृष्ठहरूमा टिप्पणीहरू असक्षम पार्दछ। <br> अस्तित्वमा रहेका टिप्पणीहरू देखाइने छैनन्।',
+
+    // Color settings
+    'color_scheme' => 'एप्लिकेसन रंग योजना',
+    'color_scheme_desc' => 'एप्लिकेसनको प्रयोगकर्ता इन्टरफेसमा प्रयोग हुने रंगहरू सेट गर्नुहोस्। रंगहरू डार्क र लाइट मोडका लागि अलग्गै सेट गर्न सकिन्छ जसले विषयवस्तु र पठनीयता सुधार गर्छ।',
+    'ui_colors_desc' => 'एप्लिकेसनको मुख्य रंग र डिफल्ट लिंक रंग सेट गर्नुहोस्। मुख्य रंग मुख्य रूपमा हेडर ब्यानर, बटनहरू र इन्टरफेस सजावटमा प्रयोग हुन्छ। डिफल्ट लिंक रंग लेखिएको सामग्री र इन्टरफेस दुवैमा प्रयोग हुन्छ।',
+    'app_color' => 'मुख्य रंग',
+    'link_color' => 'डिफल्ट लिंक रंग',
+    'content_colors_desc' => 'पृष्ठ संगठन संरचनाका सबै तत्वहरूका लागि रंग सेट गर्नुहोस्। पठनीयताको लागि डिफल्ट रंगहरूसँग मिल्दोजुल्दो चमक छनौट गर्न सुझाव दिइन्छ।',
+    'bookshelf_color' => 'शेल्फ रंग',
+    'book_color' => 'पुस्तक रंग',
+    'chapter_color' => 'अध्याय रंग',
+    'page_color' => 'पृष्ठ रंग',
+    'page_draft_color' => 'पृष्ठ मसौदा रंग',
+
+    // Registration Settings
+    'reg_settings' => 'दर्ता',
+    'reg_enable' => 'दर्ता सक्षम गर्नुहोस्',
+    'reg_enable_toggle' => 'दर्ता सक्षम गर्नुहोस्',
+    'reg_enable_desc' => 'दर्ता सक्षम हुँदा प्रयोगकर्ताले आफैंलाई एप्लिकेसन प्रयोगकर्ताको रूपमा दर्ता गर्न सक्नेछन्। दर्ता हुँदा तिनीहरूलाई डिफल्ट प्रयोगकर्ता भूमिका दिइन्छ।',
+    'reg_default_role' => 'दर्ता पछि डिफल्ट प्रयोगकर्ता भूमिका',
+    'reg_enable_external_warning' => 'बाह्य LDAP वा SAML प्रमाणीकरण सक्रिय हुँदा माथि उल्लेखित विकल्प बेवास्ता गरिनेछ। प्रमाणीकरण सफल भएमा गैर-अस्तित्व प्रयोगकर्ताका खाताहरू स्वचालित सिर्जना हुनेछ।',
+    'reg_email_confirmation' => 'इमेल पुष्टि',
+    'reg_email_confirmation_toggle' => 'इमेल पुष्टि आवश्यक छ',
+    'reg_confirm_email_desc' => 'यदि डोमेन प्रतिबन्ध प्रयोग गरिएको छ भने इमेल पुष्टि आवश्यक हुनेछ र यो विकल्प बेवास्ता गरिनेछ।',
+    'reg_confirm_restrict_domain' => 'डोमेन प्रतिबन्ध',
+    'reg_confirm_restrict_domain_desc' => 'दर्ता सीमित गर्न चाहनु भएको इमेल डोमेन्सलाई अल्पविरामले छुट्याएर प्रविष्ट गर्नुहोस्। प्रयोगकर्ताहरूलाई ठेगाना पुष्टि गर्न इमेल पठाइनेछ। <br> दर्ता सफल भएपछि प्रयोगकर्ताले इमेल ठेगाना परिवर्तन गर्न सक्नेछन्।',
+    'reg_confirm_restrict_domain_placeholder' => 'कुनै प्रतिबन्ध छैन',
+
+    // Sorting Settings
+    'sorting' => 'क्रमबद्धता',
+    'sorting_book_default' => 'डिफल्ट पुस्तक क्रम',
+    'sorting_book_default_desc' => 'नयाँ पुस्तकहरूमा लागु गर्न डिफल्ट क्रम नियम चयन गर्नुहोस्। यो अस्तित्वमा रहेका पुस्तकहरूमा असर पार्दैन र पुस्तक अनुसार ओभरराइड गर्न सकिन्छ।',
+    'sorting_rules' => 'क्रम नियमहरू',
+    'sorting_rules_desc' => 'यी पूर्वनिर्धारित क्रम सञ्चालनहरू हुन् जुन प्रणालीमा सामग्रीमा लागू गर्न सकिन्छ।',
+    'sort_rule_assigned_to_x_books' => ':count पुस्तकमा लागू गरिएको|:count पुस्तकहरूमा लागू गरिएको',
+    'sort_rule_create' => 'क्रम नियम सिर्जना गर्नुहोस्',
+    'sort_rule_edit' => 'क्रम नियम सम्पादन गर्नुहोस्',
+    'sort_rule_delete' => 'क्रम नियम मेटाउनुहोस्',
+    'sort_rule_delete_desc' => 'यस क्रम नियमलाई प्रणालीबाट हटाउनुहोस्। यस नियम प्रयोग गरिएका पुस्तकहरू म्यानुअल क्रमबद्धतामा फर्कनेछन्।',
+    'sort_rule_delete_warn_books' => 'यो क्रम नियम हाल :count पुस्तक(हरू) मा प्रयोग भैरहेको छ। के तपाईं पक्का यो मेटाउन चाहनुहुन्छ?',
+    'sort_rule_delete_warn_default' => 'यो क्रम नियम हाल पुस्तकहरूको डिफल्ट रूपमा प्रयोग भैरहेको छ। के तपाईं पक्का यो मेटाउन चाहनुहुन्छ?',
+    'sort_rule_details' => 'क्रम नियम विवरण',
+    'sort_rule_details_desc' => 'यस क्रम नियमको नाम सेट गर्नुहोस्, जुन प्रयोगकर्ताहरूले क्रम छनौट गर्दा सूचिमा देखिनेछ।',
+    'sort_rule_operations' => 'क्रम सञ्चालनहरू',
+    'sort_rule_operations_desc' => 'उपलब्ध सञ्चालनहरूको सूचीबाट क्रम क्रियाकलापहरू सेट गर्नुहोस्। प्रयोग गर्दा, माथिबाट तल सम्म क्रमसँगै लागू गरिनेछ। यहाँ गरिएको कुनै पनि परिवर्तन सुरक्षित गर्दा सबै लागू पुस्तकहरूमा लागु हुनेछ।',
+    'sort_rule_available_operations' => 'उपलब्ध सञ्चालनहरू',
+    'sort_rule_available_operations_empty' => 'कोही सञ्चालन बाँकी छैनन्',
+    'sort_rule_configured_operations' => 'कन्फिगर गरिएको सञ्चालनहरू',
+    'sort_rule_configured_operations_empty' => '"उपलब्ध सञ्चालनहरू" सूचीबाट सञ्चालनहरू तान्नुहोस्/थप्नुहोस्',
+    'sort_rule_op_asc' => '(Ascending)',
+    'sort_rule_op_desc' => '(Descending)',
+    'sort_rule_op_name' => 'नाम - वर्णानुक्रम',
+    'sort_rule_op_name_numeric' => 'नाम - सङ्ख्यात्मक',
+    'sort_rule_op_created_date' => 'सिर्जना मिति',
+    'sort_rule_op_updated_date' => 'अपडेट मिति',
+    'sort_rule_op_chapters_first' => 'पहिले अध्यायहरू',
+    'sort_rule_op_chapters_last' => 'अन्त्यमा अध्यायहरू',
+
+    // Maintenance settings
+    'maint' => 'सम्भार',
+    'maint_image_cleanup' => 'छविहरू सफा गर्नुहोस्',
+    'maint_image_cleanup_desc' => 'पृष्ठ र संस्करण सामग्री स्क्यान गरी कुन छविहरू र चित्रहरू प्रयोगमा छन् र कुनहरू अनावश्यक छन् जाँच गर्दछ। यो सञ्चालन अघि पूर्ण डाटाबेस र छवि ब्याकअप बनाउनुहोस्।',
+    'maint_delete_images_only_in_revisions' => 'पुराना पृष्ठ संस्करणहरूमा मात्र रहेका छविहरू पनि मेटाउनुहोस्',
+    'maint_image_cleanup_run' => 'सफा गर्ने प्रक्रिया सुरु गर्नुहोस्',
+    'maint_image_cleanup_warning' => ':count सम्भावित अप्रयुक्त छविहरू फेला परे। के तपाईं पक्का यी छविहरू मेटाउन चाहनुहुन्छ?',
+    'maint_image_cleanup_success' => ':count सम्भावित अप्रयुक्त छविहरू फेला परे र मेटाइयो!',
+    'maint_image_cleanup_nothing_found' => 'कुनै अप्रयुक्त छवि फेला परेन, केही मेटाइएन!',
+    'maint_send_test_email' => 'परीक्षण इमेल पठाउनुहोस्',
+    'maint_send_test_email_desc' => 'यो तपाईको प्रोफाइलमा दिइएको इमेल ठेगानामा परीक्षण इमेल पठाउँछ।',
+    'maint_send_test_email_run' => 'परीक्षण इमेल पठाउनुहोस्',
+    'maint_send_test_email_success' => 'इमेल पठाइयो :address',
+    'maint_send_test_email_mail_subject' => 'परीक्षण इमेल',
+    'maint_send_test_email_mail_greeting' => 'इमेल वितरण सफल देखिन्छ!',
+    'maint_send_test_email_mail_text' => 'बधाई छ! तपाईंले यो इमेल प्राप्त गर्नुभएकोले तपाईका इमेल सेटिङहरू ठीकसँग कन्फिगर भएका छन्।',
+    'maint_recycle_bin_desc' => 'मेटाइएका शेल्फ, पुस्तक, अध्याय र पृष्ठहरू रीसायकल बिनमा पठाइन्छ जसबाट पुनर्स्थापना वा स्थायी मेटाई गर्न सकिन्छ। पुराना वस्तुहरू प्रणाली कन्फिगरेसन अनुसार स्वचालित रूपमा हटाउन सकिन्छ।',
+    'maint_recycle_bin_open' => 'रीसायकल बिन खोल्नुहोस्',
+    'maint_regen_references' => 'सन्दर्भहरू पुनः उत्पन्न गर्नुहोस्',
+    'maint_regen_references_desc' => 'यो क्रियाले डाटाबेस भित्र वस्तुहरू बीचको सन्दर्भ सूचकांक पुनः बनाउँछ। सामान्यतया यो स्वतः हुन्छ, तर पुराना वा अनअधिकारिक विधिबाट थपिएको सामग्रीलाई सूचीकृत गर्न उपयोगी हुन्छ।',
+    'maint_regen_references_success' => 'सन्दर्भ सूचकांक पुनः उत्पन्न गरियो!',
+    'maint_timeout_command_note' => 'सूचना: यो क्रियामा समय लाग्न सक्छ जसले केही वेब वातावरणहरूमा टाइमआउट समस्या ल्याउन सक्छ। विकल्पको रूपमा टर्मिनल कमाण्ड प्रयोग गरेर गर्न सकिन्छ।',
+
+    // Recycle Bin
+    'recycle_bin' => 'रीसायकल बिन',
+    'recycle_bin_desc' => 'यहाँ तपाईंले मेटाइएका वस्तुहरू पुनर्स्थापना गर्न वा प्रणालीबाट स्थायी रूपमा हटाउन सक्नुहुन्छ। यो सूची प्रणालीका अन्य गतिविधि सूचिहरू जस्तो फिल्टर नभएको छ।',
+    'recycle_bin_deleted_item' => 'मेटाइएको वस्तु',
+    'recycle_bin_deleted_parent' => 'मूल',
+    'recycle_bin_deleted_by' => 'मेटाउने व्यक्ति',
+    'recycle_bin_deleted_at' => 'मेटाइने समय',
+    'recycle_bin_permanently_delete' => 'स्थायी रूपमा मेटाउनुहोस्',
+    'recycle_bin_restore' => 'पुन: भण्डारण गर्नुहोस्',
+    'recycle_bin_contents_empty' => 'रिसायकल बिन हाल खाली छ',
+    'recycle_bin_empty' => 'रिसायकल बिन खाली गर्नुहोस्',
+    'recycle_bin_empty_confirm' => 'यसले रिसायकल बिनभित्रका सबै वस्तुहरू र तिनीहरूको सामग्री स्थायी रूपमा मेटाउनेछ। के तपाईं पक्का खाली गर्न चाहनुहुन्छ?',
+    'recycle_bin_destroy_confirm' => 'यस क्रियाले यो वस्तु र तल सूचीबद्ध कुनै पनि सन्तान तत्वहरूलाई स्थायी रूपमा प्रणालीबाट मेटाउनेछ र तपाईंले पुनः प्राप्त गर्न सक्नुहुने छैन। के तपाईं पक्का स्थायी रूपमा मेटाउन चाहनुहुन्छ?',
+    'recycle_bin_destroy_list' => 'मेटाइने वस्तुहरू',
+    'recycle_bin_restore_list' => 'पुनर्स्थापना गरिने वस्तुहरू',
+    'recycle_bin_restore_confirm' => 'यो क्रियाले मेटाइएको वस्तु र कुनै पनि सन्तान तत्वहरूलाई मूल स्थानमा पुनर्स्थापना गर्नेछ। यदि मूल स्थान पनि मेटाइएको छ र रिसायकल बिनमा छ भने मूल वस्तुलाई पनि पुनर्स्थापना गर्नुपर्नेछ।',
+    'recycle_bin_restore_deleted_parent' => 'यस वस्तुको मूल पनि मेटाइएको छ। मूल वस्तु पुनर्स्थापित नभएसम्म यो वस्तु मेटिएको नै रहनेछ।',
+    'recycle_bin_restore_parent' => 'मूल पुनर्स्थापना गर्नुहोस्',
+    'recycle_bin_destroy_notification' => 'रिसायकल बिनबाट कुल :count वस्तुहरू मेटाइयो।',
+    'recycle_bin_restore_notification' => 'रिसायकल बिनबाट कुल :count वस्तुहरू पुनर्स्थापित गरियो।',
+
+    // Audit Log
+    'audit' => 'अडिट लग',
+    'audit_desc' => 'यो अडिट लग प्रणालीमा ट्र्याक गरिएका गतिविधिहरूको सूची देखाउँछ। यो सूची प्रणालीका समान गतिविधि सूचीहरू भन्दा फरक फिल्टररहित हुन्छ।',
+    'audit_event_filter' => 'घटना फिल्टर',
+    'audit_event_filter_no_filter' => 'फिल्टर छैन',
+    'audit_deleted_item' => 'मेटाइएको वस्तु',
+    'audit_deleted_item_name' => 'नाम: :name',
+    'audit_table_user' => 'प्रयोगकर्ता',
+    'audit_table_event' => 'घटना',
+    'audit_table_related' => 'सम्बन्धित वस्तु वा विवरण',
+    'audit_table_ip' => 'IP ठेगाना',
+    'audit_table_date' => 'गतिविधि मिति',
+    'audit_date_from' => 'मिति दायरा सुरु',
+    'audit_date_to' => 'मिति दायरा अन्त्य',
+
+    // Role Settings
+    'roles' => 'भूमिकाहरू',
+    'role_user_roles' => 'प्रयोगकर्ता भूमिका',
+    'roles_index_desc' => 'भूमिकाहरू प्रयोगकर्ताहरूलाई समूहमा राख्न र उनीहरूको सदस्यलाई प्रणाली अनुमति दिन प्रयोग हुन्छ। यदि कुनै प्रयोगकर्ता धेरै भूमिका मा छ भने तिनका अधिकारहरू जोडिनेछन् र सबै क्षमता प्राप्त हुनेछन्।',
+    'roles_x_users_assigned' => ':count प्रयोगकर्तालाई भूमिका दिइयो|:count प्रयोगकर्ताहरूलाई भूमिका दिइयो',
+    'roles_x_permissions_provided' => ':count अनुमति दिइयो|:count अनुमति दिइयो',
+    'roles_assigned_users' => 'दिइएका प्रयोगकर्ताहरू',
+    'roles_permissions_provided' => 'दिइएका अनुमति',
+    'role_create' => 'नयाँ भूमिका सिर्जना गर्नुहोस्',
+    'role_delete' => 'भूमिका मेटाउनुहोस्',
+    'role_delete_confirm' => 'यसले \':roleName\' नामको भूमिका मेटाउनेछ।',
+    'role_delete_users_assigned' => 'यस भूमिकामा :userCount प्रयोगकर्ता छन्। यदि तपाईंले यी प्रयोगकर्ताहरूलाई अर्को भूमिकामा सार्न चाहनुहुन्छ भने तल नयाँ भूमिका चयन गर्नुहोस्।',
+    'role_delete_no_migration' => "प्रयोगकर्ताहरू सार्नु हुँदैन",
+    'role_delete_sure' => 'के तपाईं पक्का यो भूमिका मेटाउन चाहनुहुन्छ?',
+    'role_edit' => 'भूमिका सम्पादन गर्नुहोस्',
+    'role_details' => 'भूमिका विवरण',
+    'role_name' => 'भूमिका नाम',
+    'role_desc' => 'भूमिकाको संक्षिप्त विवरण',
+    'role_mfa_enforced' => 'बहु-फ्याक्टर प्रमाणीकरण आवश्यक',
+    'role_external_auth_id' => 'बाह्य प्रमाणीकरण ID हरू',
+    'role_system' => 'प्रणाली अनुमति',
+    'role_manage_users' => 'प्रयोगकर्ताहरू व्यवस्थापन गर्नुहोस्',
+    'role_manage_roles' => 'भूमिका र अनुमति व्यवस्थापन गर्नुहोस्',
+    'role_manage_entity_permissions' => 'सबै पुस्तक, अध्याय र पृष्ठ अनुमति व्यवस्थापन गर्नुहोस्',
+    'role_manage_own_entity_permissions' => 'आफ्नो पुस्तक, अध्याय र पृष्ठ अनुमति व्यवस्थापन गर्नुहोस्',
+    'role_manage_page_templates' => 'पृष्ठ टेम्प्लेट व्यवस्थापन गर्नुहोस्',
+    'role_access_api' => 'प्रणाली API पहुँच',
+    'role_manage_settings' => 'एप सेटिङ व्यवस्थापन गर्नुहोस्',
+    'role_export_content' => 'सामग्री निर्यात गर्नुहोस्',
+    'role_import_content' => 'सामग्री आयात गर्नुहोस्',
+    'role_editor_change' => 'पृष्ठ सम्पादक परिवर्तन गर्नुहोस्',
+    'role_notifications' => 'सूचनाहरू प्राप्त र व्यवस्थापन गर्नुहोस्',
+    'role_asset' => 'संपत्ति अनुमति',
+    'roles_system_warning' => 'माथिका कुनै पनि तीन अनुमति प्रयोगकर्ताले आफैं वा अरूका अधिकार परिवर्तन गर्न सक्छन्। यी अनुमति भएको भूमिका मात्र भरपर्दो प्रयोगकर्तालाई दिनुहोस्।',
+    'role_asset_desc' => 'यी अनुमतिले प्रणालीभित्र सम्पत्तिमा डिफल्ट पहुँच नियन्त्रण गर्छ। पुस्तक, अध्याय र पृष्ठमा अनुमति यी भन्दा प्राथमिक हुन्छ।',
+    'role_asset_admins' => 'प्रशासनकर्ताहरूलाई सबै सामग्रीमा स्वतः पहुँच दिइन्छ, यी विकल्पहरूले UI मा देखिने वा लुकेका विकल्पहरू मात्र प्रभाव पार्न सक्छ।',
+    'role_asset_image_view_note' => 'यो छवि व्यवस्थापक भित्रको दृश्यता सम्बन्धि हो। अपलोड गरिएको छविमा वास्तविक पहुँच प्रणालीको छवि भण्डारण विकल्प अनुसार हुन्छ।',
+    'role_all' => 'सबै',
+    'role_own' => 'आफ्नो',
+    'role_controlled_by_asset' => 'अपलोड गरिएको सम्पत्तिले नियन्त्रण गरेको',
+    'role_save' => 'भूमिका सुरक्षित गर्नुहोस्',
+    'role_users' => 'यस भूमिकाका प्रयोगकर्ताहरू',
+    'role_users_none' => 'यो भूमिकामा हाल कुनै प्रयोगकर्ता छैन',
+
+    // Users
+    'users' => 'प्रयोगकर्ताहरू',
+    'users_index_desc' => 'प्रणालीमा व्यक्तिगत प्रयोगकर्ता खाता सिर्जना र व्यवस्थापन गर्नुहोस्। प्रयोगकर्ता खाता लगइन र सामग्री तथा गतिविधि जिम्मेवारीका लागि प्रयोग हुन्छ। पहुँच अनुमतिहरू मुख्यतया भूमिकामा आधारित छन् तर प्रयोगकर्ताको सामग्री स्वामित्वले पनि असर गर्न सक्छ।',
+    'user_profile' => 'प्रयोगकर्ता प्रोफाइल',
+    'users_add_new' => 'नयाँ प्रयोगकर्ता थप्नुहोस्',
+    'users_search' => 'प्रयोगकर्ताहरू खोज्नुहोस्',
+    'users_latest_activity' => 'हालैको गतिविधि',
+    'users_details' => 'प्रयोगकर्ता विवरण',
+    'users_details_desc' => 'यस प्रयोगकर्ताको प्रदर्शन नाम र इमेल ठेगाना सेट गर्नुहोस्। इमेल ठेगाना लगइनका लागि प्रयोग हुनेछ।',
+    'users_details_desc_no_email' => 'यो प्रयोगकर्तालाई अरूले चिन्नेगरी प्रदर्शन नाम सेट गर्नुहोस्।',
+    'users_role' => 'प्रयोगकर्ता भूमिका',
+    'users_role_desc' => 'यो प्रयोगकर्तालाई दिइने भूमिका चयन गर्नुहोस्। प्रयोगकर्ताले धेरै भूमिका पाएमा सबै भूमिका अधिकारहरू जोडिनेछन्।',
+    'users_password' => 'प्रयोगकर्ता पासवर्ड',
+    'users_password_desc' => 'लगइनका लागि कम्तिमा ८ वर्ण लामो पासवर्ड सेट गर्नुहोस्।',
+    'users_send_invite_text' => 'तपाईं यो प्रयोगकर्तालाई निमन्त्रणा इमेल पठाउन सक्नुहुन्छ जसले उनीहरूलाई आफ्नै पासवर्ड सेट गर्न अनुमति दिन्छ, नभए तपाईंले आफैं पासवर्ड सेट गर्न सक्नुहुन्छ।',
+    'users_send_invite_option' => 'प्रयोगकर्तालाई निमन्त्रणा इमेल पठाउनुहोस्',
+    'users_external_auth_id' => 'बाह्य प्रमाणीकरण ID',
+    'users_external_auth_id_desc' => 'जब बाह्य प्रमाणीकरण प्रणाली प्रयोग हुन्छ (जस्तै SAML2, OIDC, LDAP), यो ID ले यो BookStack प्रयोगकर्तालाई सम्बन्धित प्रणाली खातासँग जोड्छ। सामान्य इमेल प्रमाणीकरणमा यो फिल्ड आवश्यक छैन।',
+    'users_password_warning' => 'यो प्रयोगकर्ताको पासवर्ड परिवर्तन गर्न मात्र तल भर्नुहोस्।',
+    'users_system_public' => 'यो प्रयोगकर्ता कुनै पनि पाहुना प्रयोगकर्तालाई प्रतिनिधित्व गर्दछ। यसले लगइन गर्न सक्दैन तर स्वतः दिइन्छ।',
+    'users_delete' => 'प्रयोगकर्ता मेटाउनुहोस्',
+    'users_delete_named' => ':userName प्रयोगकर्ता मेटाउनुहोस्',
+    'users_delete_warning' => 'यसले \':userName\' नामको प्रयोगकर्तालाई प्रणालीबाट पूर्ण रूपमा मेटाउनेछ।',
+    'users_delete_confirm' => 'के तपाईं पक्का यो प्रयोगकर्ता मेटाउन चाहनुहुन्छ?',
+    'users_migrate_ownership' => 'स्वामित्व सार्नुहोस्',
+    'users_migrate_ownership_desc' => 'यहाँ अर्को प्रयोगकर्ता चयन गर्नुहोस् जसले यस प्रयोगकर्ताका सबै वस्तुहरूको स्वामित्व पाओस्।',
+    'users_none_selected' => 'कुनै प्रयोगकर्ता चयन गरिएको छैन',
+    'users_edit' => 'प्रयोगकर्ता सम्पादन गर्नुहोस्',
+    'users_edit_profile' => 'प्रोफाइल सम्पादन गर्नुहोस्',
+    'users_avatar' => 'प्रयोगकर्ता अवतार',
+    'users_avatar_desc' => 'यो प्रयोगकर्तालाई प्रतिनिधित्व गर्न एउटा चित्र चयन गर्नुहोस्। करिब २५६px वर्गाकार हुनु पर्छ।',
+    'users_preferred_language' => 'रुचाइको भाषा',
+    'users_preferred_language_desc' => 'यस विकल्पले एपको यूजर-इन्टरफेसको भाषा परिवर्तन गर्नेछ। प्रयोगकर्ताले सिर्जना गरेको सामग्रीमा असर पार्दैन।',
+    'users_social_accounts' => 'सामाजिक खाता',
+    'users_social_accounts_desc' => 'यो प्रयोगकर्ताका जडित सामाजिक खाताहरूको स्थिति हेर्नुहोस्। सामाजिक खाताहरू प्रमाणीकरणका लागि प्राथमिक प्रणालीसँगै प्रयोग गर्न सकिन्छ।',
+    'users_social_accounts_info' => 'यहाँ तपाईं आफ्नो अन्य खाताहरू छिटो र सजिलो लगइनका लागि जोड्न सक्नुहुन्छ। यहाँबाट खाता डिस्कनेक्ट गर्दा पूर्व अनुमति रद्द हुँदैन। अनुमति रद्द गर्न सामाजिक खाताको सेटिङ प्रयोग गर्नुहोस्।',
+    'users_social_connect' => 'खाता जडान गर्नुहोस्',
+    'users_social_disconnect' => 'खाता डिस्कनेक्ट गर्नुहोस्',
+    'users_social_status_connected' => 'जडान गरिएको',
+    'users_social_status_disconnected' => 'डिस्कनेक्ट गरिएको',
+    'users_social_connected' => ':socialAccount खाता सफलतापूर्वक प्रोफाइलमा जोडियो।',
+    'users_social_disconnected' => ':socialAccount खाता सफलतापूर्वक प्रोफाइलबाट हटाइयो।',
+    'users_api_tokens' => 'API टोकनहरू',
+    'users_api_tokens_desc' => 'BookStack REST API सँग प्रमाणीकरण गर्न प्रयोग गरिने पहुँच टोकनहरू सिर्जना र व्यवस्थापन गर्नुहोस्। API अनुमतिहरू टोकनधारक प्रयोगकर्ताबाट व्यवस्थापन हुन्छ।',
+    'users_api_tokens_none' => 'यस प्रयोगकर्ताका लागि कुनै API टोकन सिर्जना गरिएको छैन',
+    'users_api_tokens_create' => 'टोकन सिर्जना गर्नुहोस्',
+    'users_api_tokens_expires' => 'म्याद समाप्त',
+    'users_api_tokens_docs' => 'API कागजातहरू',
+    'users_mfa' => 'बहु-फ्याक्टर प्रमाणीकरण',
+    'users_mfa_desc' => 'तपाईंको प्रयोगकर्ता खाताको लागि थप सुरक्षा तहको रूपमा बहु-फ्याक्टर प्रमाणीकरण सेटअप गर्नुहोस्।',
+    'users_mfa_x_methods' => ':count विधि सेटअप गरिएको|:count विधिहरू सेटअप गरिएको',
+    'users_mfa_configure' => 'विधिहरू सेटअप गर्नुहोस्',
+
+    // API Tokens
+    'user_api_token_create' => 'API टोकन सिर्जना गर्नुहोस्',
+    'user_api_token_name' => 'नाम',
+    'user_api_token_name_desc' => 'यो टोकनको उद्देश्य सम्झनको लागि भविष्यमा सम्झन सकिने नाम दिनुहोस्।',
+    'user_api_token_expiry' => 'म्याद समाप्ति मिति',
+    'user_api_token_expiry_desc' => 'यो टोकनको म्याद समाप्त हुने मिति सेट गर्नुहोस्। यस मितिपछि, यस टोकनको प्रयोग गरेर गरिएका अनुरोधहरू काम गर्दैनन्। यो फिल्ड खाली छोड्दा भविष्यमा १०० वर्षको म्याद सेट हुनेछ।',
+    'user_api_token_create_secret_message' => 'यो टोकन सिर्जना गरेपछि "Token ID" र "Token Secret" जनरेट र प्रदर्शन गरिनेछ। यो गोप्य जानकारी एक पटक मात्र देखाइनेछ, त्यसैले कृपया यसलाई सुरक्षित स्थानमा प्रतिलिपि गर्नुहोस् र त्यसपछि मात्र अगाडि बढ्नुहोस्।',
+    'user_api_token' => 'API टोकन',
+    'user_api_token_id' => 'टोकन ID',
+    'user_api_token_id_desc' => 'यो टोकनको लागि प्रणालीद्वारा उत्पन्न गरिएको अ-सम्पादनयोग्य पहिचान हो, जुन API अनुरोधहरूमा प्रदान गर्न आवश्यक हुनेछ।',
+    'user_api_token_secret' => 'टोकन गोप्य जानकारी',
+    'user_api_token_secret_desc' => 'यो टोकनको लागि प्रणालीद्वारा उत्पन्न गरिएको गोप्य जानकारी हो, जुन API अनुरोधहरूमा प्रदान गर्न आवश्यक हुनेछ। यसलाई केवल एक पटक मात्र देखाइनेछ, त्यसैले कृपया यसलाई सुरक्षित स्थानमा प्रतिलिपि गर्नुहोस्।',
+    'user_api_token_created' => 'टोकन सिर्जना भएको :timeAgo',
+    'user_api_token_updated' => 'टोकन अपडेट भएको :timeAgo',
+    'user_api_token_delete' => 'टोकन मेटाउनुहोस्',
+    'user_api_token_delete_warning' => 'यसले \':tokenName\' नामको API टोकनलाई पूर्ण रूपमा प्रणालीबाट मेटाउनेछ।',
+    'user_api_token_delete_confirm' => 'के तपाईं पक्का यो API टोकन मेटाउन चाहनुहुन्छ?',
+
+    // Webhooks
+    'webhooks' => 'वेबहुक्स',
+    'webhooks_index_desc' => 'वेबहुक्स भनेको प्रणाली भित्रका केही क्रियाकलाप र घटनाहरू हुँदा बाह्य URL हरूमा डेटा पठाउने विधि हो, जसले बाह्य प्लेटफर्महरूसँग जस्तै सन्देश वा सूचनासम्बन्धी सिस्टमहरूसँग घटनामा आधारित एकीकरणलाई अनुमति दिन्छ।',
+    'webhooks_x_trigger_events' => ':count ट्रिगर घटना|:count ट्रिगर घटनाहरू',
+    'webhooks_create' => 'नयाँ वेबहुक सिर्जना गर्नुहोस्',
+    'webhooks_none_created' => 'अझै कुनै वेबहुक सिर्जना गरिएको छैन।',
+    'webhooks_edit' => 'वेबहुक सम्पादन गर्नुहोस्',
+    'webhooks_save' => 'वेबहुक बचत गर्नुहोस्',
+    'webhooks_details' => 'वेबहुक विवरण',
+    'webhooks_details_desc' => 'एक प्रयोगकर्ता मैत्री नाम र एक POST इन्डप्वाइंट दिनुहोस् जसलाई वेबहुकको डेटा पठाइने स्थानको रूपमा प्रयोग हुनेछ।',
+    'webhooks_events' => 'वेबहुक घटनाहरू',
+    'webhooks_events_desc' => 'यी घटनाहरू चयन गर्नुहोस् जसले यो वेबहुकलाई ट्रिगर गर्नुपर्नेछ।',
+    'webhooks_events_warning' => 'ध्यान दिनुहोस् कि यी घटनाहरू चयन गरेपछि सबै चयन गरिएका घटनाहरूको लागि वेबहुक ट्रिगर हुनेछ, भले नै कस्टम अनुमतिहरू लागू गरिएका छन्। यो वेबहुक प्रयोग गर्दा गोपनीय सामग्रीको जोखिम नहोस् भन्ने कुरा सुनिश्चित गर्नुहोस्।',
+    'webhooks_events_all' => 'सिस्टमका सबै घटनाहरू',
+    'webhooks_name' => 'वेबहुक नाम',
+    'webhooks_timeout' => 'वेबहुक अनुरोध म्याद समाप्ति (सेकेन्ड)',
+    'webhooks_endpoint' => 'वेबहुक इन्डप्वाइंट',
+    'webhooks_active' => 'वेबहुक सक्रिय',
+    'webhook_events_table_header' => 'घटनाहरू',
+    'webhooks_delete' => 'वेबहुक मेटाउनुहोस्',
+    'webhooks_delete_warning' => 'यसले \':webhookName\' नामको वेबहुकलाई प्रणालीबाट पूर्ण रूपमा मेटाउनेछ।',
+    'webhooks_delete_confirm' => 'के तपाईं पक्का यो वेबहुक मेटाउन चाहनुहुन्छ?',
+    'webhooks_format_example' => 'वेबहुक ढाँचाको उदाहरण',
+    'webhooks_format_example_desc' => 'वेबहुक डेटा POST अनुरोधको रूपमा JSON ढाँचामा निर्धारित इन्डप्वाइंटमा पठाइन्छ। "related_item" र "url" गुणहरू वैकल्पिक छन् र यो ट्रिगर गरिएको घटनाको प्रकारमा निर्भर गर्नेछ।',
+    'webhooks_status' => 'वेबहुक स्थिति',
+    'webhooks_last_called' => 'अन्तिम पटक कल गरिएको: ',
+    'webhooks_last_errored' => 'अन्तिम पटक एरर भएको: ',
+    'webhooks_last_error_message' => 'अन्तिम एरर सन्देश: ',
+
+    // Licensing
+    'licenses' => 'लाइसन्स',
+    'licenses_desc' => 'यस पृष्ठमा BookStack को लाइसेन्स जानकारी र BookStack भित्र प्रयोग भएका परियोजना र पुस्तकालयहरूको जानकारी दिइएको छ। सूचीबद्ध भएका धेरै परियोजनाहरूले केवल विकासको सन्दर्भमा मात्र प्रयोग गर्न सकिन्छ।',
+    'licenses_bookstack' => 'BookStack लाइसेन्स',
+    'licenses_php' => 'PHP पुस्तकालय लाइसेन्स',
+    'licenses_js' => 'JavaScript पुस्तकालय लाइसेन्स',
+    'licenses_other' => 'अन्य लाइसेन्स',
+    'license_details' => 'लाइसेन्स विवरण',
+
+    //! If editing translations files directly please ignore this in all
+    //! languages apart from en. Content will be auto-copied from en.
+    //!////////////////////////////////
+    'language_select' => [
+        'en' => 'English',
+        'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
+        'cs' => 'Česky',
+        'cy' => 'Cymraeg',
+        'da' => 'Dansk',
+        'de' => 'Deutsch (Sie)',
+        'de_informal' => 'Deutsch (Du)',
+        'el' => 'ελληνικά',
+        'es' => 'Español',
+        'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
+        'eu' => 'Euskara',
+        'fa' => 'فارسی',
+        'fi' => 'Suomi',
+        'fr' => 'Français',
+        'he' => 'עברית',
+        'hr' => 'Hrvatski',
+        'hu' => 'Magyar',
+        'id' => 'Bahasa Indonesia',
+        'it' => 'Italian',
+        'ja' => '日本語',
+        'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
+        'lv' => 'Latviešu Valoda',
+        'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
+        'nn' => 'Nynorsk',
+        'nl' => 'Nederlands',
+        'pl' => 'Polski',
+        'pt' => 'Português',
+        'pt_BR' => 'Português do Brasil',
+        'ro' => 'Română',
+        'ru' => 'Русский',
+        'sk' => 'Slovensky',
+        'sl' => 'Slovenščina',
+        'sv' => 'Svenska',
+        'tr' => 'Türkçe',
+        'uk' => 'Українська',
+        'uz' => 'O‘zbekcha',
+        'vi' => 'Tiếng Việt',
+        'zh_CN' => '简体中文',
+        'zh_TW' => '繁體中文',
+    ],
+    //!////////////////////////////////
+];
diff --git a/lang/ne/validation.php b/lang/ne/validation.php
new file mode 100644
index 000000000..f00c0f731
--- /dev/null
+++ b/lang/ne/validation.php
@@ -0,0 +1,122 @@
+<?php
+/**
+ * Validation Lines
+ * The following language lines contain the default error messages used by
+ * the validator class. Some of these rules have multiple versions such
+ * as the size rules. Feel free to tweak each of these messages here.
+ */
+return [
+
+    // Standard laravel validation lines
+    'accepted'             => ':attribute स्वीकार गर्नुपर्छ।',
+    'active_url'           => ':attribute मान्य URL होइन।',
+    'after'                => ':attribute मिति :date पछिको हुनुपर्छ।',
+    'alpha'                => ':attribute मा अक्षर मात्र हुनुपर्छ।',
+    'alpha_dash'           => ':attribute मा अक्षर, अंक, ड्यास (-) र अन्डरस्कोर (_) मात्र हुनुपर्छ।',
+    'alpha_num'            => ':attribute मा अक्षर र अंक मात्र हुनुपर्छ।',
+    'array'                => ':attribute array हुनुपर्छ।',
+    'backup_codes'         => 'दिइएको कोड गलत छ वा पहिल्यै प्रयोग भइसकेको छ।',
+    'before'               => ':attribute मिति :date भन्दा पहिला हुनुपर्छ।',
+    'between'              => [
+        'numeric' => ':attribute :min देखि :max बीचमा हुनुपर्छ।',
+        'file'    => ':attribute :min देखि :max किलोबाइट बीचमा हुनुपर्छ।',
+        'string'  => ':attribute :min देखि :max क्यारेक्टरबीच हुनुपर्छ।',
+        'array'   => ':attribute मा :min देखि :max वस्तुहरू हुनुपर्छ।',
+    ],
+    'boolean'              => ':attribute साँचो (true) वा झूटो (false) हुनुपर्छ।',
+    'confirmed'            => ':attribute पुष्टि मिलेन।',
+    'date'                 => ':attribute मान्य मिति होइन।',
+    'date_format'          => ':attribute ढाँचा :format सँग मेल खाँदैन।',
+    'different'            => ':attribute र :other फरक हुनुपर्छ।',
+    'digits'               => ':attribute मा ठीक :digits अंक हुनुपर्छ।',
+    'digits_between'       => ':attribute मा :min देखि :max अंक हुनुपर्छ।',
+    'email'                => ':attribute मान्य ईमेल ठेगाना हुनुपर्छ।',
+    'ends_with' => ':attribute यी मध्ये एकले अन्त्य हुनुपर्छ: :values',
+    'file'                 => ':attribute मान्य फाइल हुनुपर्छ।',
+    'filled'               => ':attribute आवश्यक छ।',
+    'gt'                   => [
+        'numeric' => ':attribute :value भन्दा बढी हुनुपर्छ।',
+        'file'    => ':attribute :value किलोबाइटभन्दा बढी हुनुपर्छ।',
+        'string'  => ':attribute :value क्यारेक्टरभन्दा बढी हुनुपर्छ।',
+        'array'   => ':attribute मा :value भन्दा बढी वस्तुहरू हुनुपर्छ।',
+    ],
+    'gte'                  => [
+        'numeric' => ':attribute :value भन्दा बढी वा बराबर हुनुपर्छ।',
+        'file'    => ':attribute :value किलोबाइटभन्दा बढी वा बराबर हुनुपर्छ।',
+        'string'  => ':attribute :value क्यारेक्टरभन्दा बढी वा बराबर हुनुपर्छ।',
+        'array'   => ':attribute मा कम्तीमा :value वस्तुहरू हुनुपर्छ।',
+    ],
+    'exists'               => 'चयन गरिएको :attribute अमान्य छ।',
+    'image'                => ':attribute एउटा तस्बिर हुनुपर्छ।',
+    'image_extension'      => ':attribute मा मान्य र समर्थित तस्बिर विस्तार (extension) हुनुपर्छ।',
+    'in'                   => 'चयन गरिएको :attribute अमान्य छ।',
+    'integer'              => ':attribute पूर्णांक (integer) हुनुपर्छ।',
+    'ip'                   => ':attribute मान्य IP ठेगाना हुनुपर्छ।',
+    'ipv4'                 => ':attribute मान्य IPv4 ठेगाना हुनुपर्छ।',
+    'ipv6'                 => ':attribute मान्य IPv6 ठेगाना हुनुपर्छ।',
+    'json'                 => ':attribute मान्य JSON स्ट्रिङ हुनुपर्छ।',
+    'lt'                   => [
+        'numeric' => ':attribute :value भन्दा कम हुनुपर्छ।',
+        'file'    => ':attribute :value किलोबाइटभन्दा कम हुनुपर्छ।',
+        'string'  => ':attribute :value क्यारेक्टरभन्दा कम हुनुपर्छ।',
+        'array'   => ':attribute मा :value भन्दा कम वस्तुहरू हुनुपर्छ।',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute :value भन्दा कम वा बराबर हुनुपर्छ।',
+        'file'    => ':attribute :value किलोबाइटभन्दा कम वा बराबर हुनुपर्छ।',
+        'string'  => ':attribute :value क्यारेक्टरभन्दा कम वा बराबर हुनुपर्छ।',
+        'array'   => ':attribute मा :value भन्दा बढी वस्तुहरू हुनु हुँदैन।',
+    ],
+    'max'                  => [
+        'numeric' => ':attribute :max भन्दा बढी हुन सक्दैन।',
+        'file'    => ':attribute :max किलोबाइटभन्दा बढी हुन सक्दैन।',
+        'string'  => ':attribute :max क्यारेक्टरभन्दा बढी हुन सक्दैन।',
+        'array'   => ':attribute मा :max भन्दा बढी वस्तुहरू हुनु हुँदैन।',
+    ],
+    'mimes'                => ':attribute फाइलको प्रकार :values हुनुपर्छ।',
+    'min'                  => [
+        'numeric' => ':attribute कम्तीमा :min हुनुपर्छ।',
+        'file'    => ':attribute कम्तीमा :min किलोबाइट हुनुपर्छ।',
+        'string'  => ':attribute कम्तीमा :min क्यारेक्टर हुनुपर्छ।',
+        'array'   => ':attribute मा कम्तीमा :min वस्तुहरू हुनुपर्छ।',
+    ],
+    'not_in'               => 'चयन गरिएको :attribute अमान्य छ।',
+    'not_regex'            => ':attribute को ढाँचा अमान्य छ।',
+    'numeric'              => ':attribute संख्या हुनुपर्छ।',
+    'regex'                => ':attribute ढाँचा अमान्य छ।',
+    'required'             => ':attribute आवश्यक छ।',
+    'required_if'          => ':other :value हुँदा :attribute आवश्यक हुन्छ।',
+    'required_with'        => ':values भएमा :attribute आवश्यक छ।',
+    'required_with_all'    => ':values भएमा :attribute आवश्यक छ।',
+    'required_without'     => ':values नभएमा :attribute आवश्यक छ।',
+    'required_without_all' => ':values मध्ये कुनै पनि नभएमा :attribute आवश्यक छ।',
+    'same'                 => ':attribute र :other मिल्नुपर्छ।',
+    'safe_url'             => 'दिएको लिङ्क सुरक्षित नहुन सक्छ।',
+    'size'                 => [
+        'numeric' => ':attribute ठीक :size हुनुपर्छ।',
+        'file'    => ':attribute ठीक :size किलोबाइट हुनुपर्छ।',
+        'string'  => ':attribute ठीक :size क्यारेक्टर हुनुपर्छ।',
+        'array'   => ':attribute मा ठीक :size वस्तुहरू हुनुपर्छ।',
+    ],
+    'string'               => ':attribute स्ट्रिङ (पाठ) हुनुपर्छ।',
+    'timezone'             => ':attribute मान्य समय क्षेत्र (timezone) हुनुपर्छ।',
+    'totp'                 => 'दिएको कोड गलत छ वा सकिएको छ।',
+    'unique'               => ':attribute पहिल्यै प्रयोग भइसकेको छ।',
+    'url'                  => ':attribute को ढाँचा अमान्य छ।',
+    'uploaded'             => 'फाइल अपलोड हुन सकेन। सर्भरले यस्तो साइज स्वीकार नगर्न सक्छ।',
+
+    'zip_file' => ':attribute ले ZIP फाइलभित्रको फाइल देखाउनु पर्छ।',
+    'zip_file_mime' => ':attribute मा :validTypes प्रकारको फाइल हुनुपर्छ, तर :foundType भेटियो।',
+    'zip_model_expected' => 'डेटा वस्तु चाहिएको थियो तर ":type" भेटियो।',
+    'zip_unique' => ':attribute ZIP भित्रको वस्तु प्रकारको लागि अद्वितीय हुनुपर्छ।',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'पासवर्ड पुष्टि आवश्यक छ।',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
diff --git a/lang/nl/activities.php b/lang/nl/activities.php
index 3d548e2a7..230c7a58b 100644
--- a/lang/nl/activities.php
+++ b/lang/nl/activities.php
@@ -122,7 +122,7 @@ return [
     'recycle_bin_destroy' => 'verwijderde van prullenbak',
 
     // Comments
-    'commented_on'                => 'reageerde op',
+    'commented_on'                => 'plaatste opmerking in',
     'comment_create'              => 'voegde opmerking toe',
     'comment_update'              => 'paste opmerking aan',
     'comment_delete'              => 'verwijderde opmerking',
diff --git a/lang/nl/common.php b/lang/nl/common.php
index ca713a243..bcd7fbe08 100644
--- a/lang/nl/common.php
+++ b/lang/nl/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Aanmaken',
     'update' => 'Bijwerken',
     'edit' => 'Bewerk',
+    'archive' => 'Archiveer',
+    'unarchive' => 'Terughalen',
     'sort' => 'Sorteer',
     'move' => 'Verplaats',
     'copy' => 'Kopieer',
@@ -38,7 +40,7 @@ return [
     'delete_confirm' => 'Verwijdering bevestigen',
     'search' => 'Zoek',
     'search_clear' => 'Zoekopdracht wissen',
-    'reset' => 'Reset',
+    'reset' => 'Wissen',
     'remove' => 'Verwijder',
     'add' => 'Voeg toe',
     'configure' => 'Configureer',
diff --git a/lang/nl/editor.php b/lang/nl/editor.php
index e3d6df6bc..cc3ad108c 100644
--- a/lang/nl/editor.php
+++ b/lang/nl/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superscript',
     'subscript' => 'Subscript',
     'text_color' => 'Tekstkleur',
+    'highlight_color' => 'Accentkleur',
     'custom_color' => 'Aangepaste kleur',
     'remove_color' => 'Verwijder kleur',
     'background_color' => 'Tekstmarkeringskleur',
diff --git a/lang/nl/entities.php b/lang/nl/entities.php
index 198123018..b9be1f42b 100644
--- a/lang/nl/entities.php
+++ b/lang/nl/entities.php
@@ -23,7 +23,7 @@ return [
     'meta_updated' => 'Bijgewerkt: :timeLength',
     'meta_updated_name' => 'Bijgewerkt: :timeLength door :user',
     'meta_owned_name' => 'Eigendom van :user',
-    'meta_reference_count' => 'Gerefereerd door :count item|Gerefereerd door :count items',
+    'meta_reference_count' => 'Verwijzing in :count item|Verwijzing in :count items',
     'entity_select' => 'Entiteit selecteren',
     'entity_select_lack_permission' => 'Je hebt niet de vereiste machtiging om dit item te selecteren',
     'images' => 'Afbeeldingen',
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Dit zal het Zip-bestand van de import permanent verwijderen.',
     'import_errors' => 'Importeerfouten',
     'import_errors_desc' => 'De volgende fouten deden zich voor tijdens het importeren:',
+    'breadcrumb_siblings_for_page' => 'Navigeer pagina\'s op hetzelfde niveau',
+    'breadcrumb_siblings_for_chapter' => 'Navigeer hoofdstukken op hetzelfde niveau',
+    'breadcrumb_siblings_for_book' => 'Navigeer boeken op hetzelfde niveau',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigeer boekenplanken op hetzelfde niveau',
 
     // Permissions and restrictions
     'permissions' => 'Machtigingen',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stabiele Inhoud)',
     'pages_edit_switch_to_wysiwyg' => 'Schakel naar de WYSIWYG Bewerker',
     'pages_edit_switch_to_new_wysiwyg' => 'Schakel naar de nieuwe WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alfa-testfase)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta-testfase)',
     'pages_edit_set_changelog' => 'Logboek instellen',
     'pages_edit_enter_changelog_desc' => 'Geef een korte omschrijving van de wijzigingen die je gemaakt hebt',
     'pages_edit_enter_changelog' => 'Voeg toe aan logboek',
@@ -268,10 +272,11 @@ return [
     'pages_md_insert_drawing' => 'Tekening invoegen',
     'pages_md_show_preview' => 'Toon voorbeeld',
     'pages_md_sync_scroll' => 'Synchroniseer scrollen van voorbeeld',
+    'pages_md_plain_editor' => 'Normale tekst bewerker',
     'pages_drawing_unsaved' => 'Niet-opgeslagen Tekening Gevonden',
     'pages_drawing_unsaved_confirm' => 'Er zijn niet-opgeslagen tekeninggegevens gevonden van een eerdere mislukte poging om de tekening op te slaan. Wil je deze niet-opgeslagen tekening herstellen en verder bewerken?',
     'pages_not_in_chapter' => 'Pagina is niet in een hoofdstuk',
-    'pages_move' => 'Pagina verplaatsten',
+    'pages_move' => 'Pagina Verplaatsen',
     'pages_copy' => 'Pagina kopiëren',
     'pages_copy_desination' => 'Kopieër bestemming',
     'pages_copy_success' => 'Pagina succesvol gekopieerd',
@@ -389,21 +394,30 @@ return [
     'profile_not_created_shelves' => ':userName heeft geen boekenplanken gemaakt',
 
     // Comments
-    'comment' => 'Reactie',
-    'comments' => 'Reacties',
-    'comment_add' => 'Reactie toevoegen',
-    'comment_placeholder' => 'Laat hier een reactie achter',
-    'comment_count' => '{0} Geen reacties|{1} 1 Reactie|[2,*] :count Reacties',
-    'comment_save' => 'Sla reactie op',
-    'comment_new' => 'Nieuwe reactie',
-    'comment_created' => 'reactie gegeven :createDiff',
+    'comment' => 'Opmerking',
+    'comments' => 'Opmerkingen',
+    'comment_add' => 'Opmerking toevoegen',
+    'comment_none' => 'Geen opmerkingen om weer te geven',
+    'comment_placeholder' => 'Laat hier een opmerking achter',
+    'comment_thread_count' => ':count Opmerking Thread|:count Opmerking Threads',
+    'comment_archived_count' => ':count Gearchiveerd',
+    'comment_archived_threads' => 'Gearchiveerde Threads',
+    'comment_save' => 'Sla opmerking op',
+    'comment_new' => 'Nieuwe opmerking',
+    'comment_created' => 'opmerking gegeven :createDiff',
     'comment_updated' => 'Updatet :updateDiff door :username',
     'comment_updated_indicator' => 'Bijgewerkt',
-    'comment_deleted_success' => 'Reactie verwijderd',
-    'comment_created_success' => 'Reactie toegevoegd',
-    'comment_updated_success' => 'Reactie bijgewerkt',
-    'comment_delete_confirm' => 'Weet je zeker dat je deze reactie wilt verwijderen?',
+    'comment_deleted_success' => 'Opmerking verwijderd',
+    'comment_created_success' => 'Opmerking toegevoegd',
+    'comment_updated_success' => 'Opmerking bijgewerkt',
+    'comment_archive_success' => 'Opmerking gearchiveerd',
+    'comment_unarchive_success' => 'Opmerking teruggehaald',
+    'comment_view' => 'Opmerking weergeven',
+    'comment_jump_to_thread' => 'Ga naar thread',
+    'comment_delete_confirm' => 'Weet je zeker dat je deze opmerking wilt verwijderen?',
     'comment_in_reply_to' => 'Als antwoord op :commentId',
+    'comment_reference' => 'Verwijzing',
+    'comment_reference_outdated' => '(Verouderd)',
     'comment_editor_explain' => 'Hier zijn de opmerkingen die zijn achtergelaten op deze pagina. Opmerkingen kunnen worden toegevoegd en beheerd wanneer u de opgeslagen pagina bekijkt.',
 
     // Revision
@@ -452,11 +466,11 @@ return [
     'watch_desc_comments_page' => 'Geef een melding van pagina wijzigingen en nieuwe opmerkingen.',
     'watch_change_default' => 'Standaardvoorkeuren voor meldingen wijzigen',
     'watch_detail_ignore' => 'Meldingen negeren',
-    'watch_detail_new' => 'Op de uitkijk voor nieuwe pagina\'s',
-    'watch_detail_updates' => 'Op de uitkijk voor nieuwe pagina\'s en aanpassingen',
-    'watch_detail_comments' => 'Op de uitkijk voor nieuwe pagina\'s, aanpassingen en opmerkingen',
-    'watch_detail_parent_book' => 'Op de uitkijk via hogerliggend boek',
-    'watch_detail_parent_book_ignore' => 'Aan het negeren via hogerliggend boek',
-    'watch_detail_parent_chapter' => 'Op de uitkijk via hogerliggend hoofdstuk',
-    'watch_detail_parent_chapter_ignore' => 'Aan het negeren via hogerliggend hoofdstuk',
+    'watch_detail_new' => 'Nieuwe pagina\'s aan het volgen',
+    'watch_detail_updates' => 'Nieuwe pagina\'s en aanpassingen aan het volgen',
+    'watch_detail_comments' => 'Nieuwe pagina\'s, aanpassingen en opmerkingen aan het volgen',
+    'watch_detail_parent_book' => 'Aan het volgen via bovenliggend boek',
+    'watch_detail_parent_book_ignore' => 'Aan het negeren via bovenliggend boek',
+    'watch_detail_parent_chapter' => 'Aan het volgen via bovenliggend hoofdstuk',
+    'watch_detail_parent_chapter_ignore' => 'Aan het negeren via bovenliggend hoofdstuk',
 ];
diff --git a/lang/nl/errors.php b/lang/nl/errors.php
index d8d5af723..9829986a2 100644
--- a/lang/nl/errors.php
+++ b/lang/nl/errors.php
@@ -87,11 +87,11 @@ return [
     'role_cannot_remove_only_admin' => 'Deze gebruiker is de enige gebruiker die is toegewezen aan de beheerdersrol. Wijs de beheerdersrol toe aan een andere gebruiker voordat u probeert deze hier te verwijderen.',
 
     // Comments
-    'comment_list' => 'Er is een fout opgetreden tijdens het ophalen van de reacties.',
-    'cannot_add_comment_to_draft' => 'Je kunt geen reacties toevoegen aan een concept.',
-    'comment_add' => 'Er is een fout opgetreden tijdens het aanpassen / toevoegen van de reactie.',
-    'comment_delete' => 'Er is een fout opgetreden tijdens het verwijderen van de reactie.',
-    'empty_comment' => 'Kan geen lege reactie toevoegen.',
+    'comment_list' => 'Er is een fout opgetreden tijdens het ophalen van de opmerkingen.',
+    'cannot_add_comment_to_draft' => 'Je kunt geen opmerkingen toevoegen aan een concept.',
+    'comment_add' => 'Er is een fout opgetreden tijdens het aanpassen / toevoegen van de opmerking.',
+    'comment_delete' => 'Er is een fout opgetreden tijdens het verwijderen van de opmerking.',
+    'empty_comment' => 'Kan geen lege opmerking toevoegen.',
 
     // Error pages
     '404_page_not_found' => 'Pagina Niet Gevonden',
diff --git a/lang/nl/notifications.php b/lang/nl/notifications.php
index bc5f380c3..1e7035670 100644
--- a/lang/nl/notifications.php
+++ b/lang/nl/notifications.php
@@ -5,7 +5,7 @@
 return [
 
     'new_comment_subject' => 'Nieuwe opmerking op pagina: :pageName',
-    'new_comment_intro' => 'Een gebruiker heeft gereageerd op een pagina in :appName:',
+    'new_comment_intro' => 'Een gebruiker heeft een opmerking geplaatst op een pagina in :appName:',
     'new_page_subject' => 'Nieuwe pagina: :pageName',
     'new_page_intro' => 'Een nieuwe pagina is gemaakt in :appName:',
     'updated_page_subject' => 'Aangepaste pagina: :pageName',
@@ -14,7 +14,7 @@ return [
 
     'detail_page_name' => 'Pagina Naam:',
     'detail_page_path' => 'Paginapad:',
-    'detail_commenter' => 'Reageerder:',
+    'detail_commenter' => 'Commentator:',
     'detail_comment' => 'Opmerking:',
     'detail_created_by' => 'Gemaakt Door:',
     'detail_updated_by' => 'Aangepast Door:',
diff --git a/lang/nl/preferences.php b/lang/nl/preferences.php
index 5e385276c..d0e3623b9 100644
--- a/lang/nl/preferences.php
+++ b/lang/nl/preferences.php
@@ -27,7 +27,7 @@ return [
     'notifications_save' => 'Voorkeuren opslaan',
     'notifications_update_success' => 'Voorkeuren voor meldingen zijn bijgewerkt!',
     'notifications_watched' => 'Gevolgde & Genegeerde Items',
-    'notifications_watched_desc' => 'Hieronder staan de items waarvoor aangepaste \'Volg\'-voorkeuren zijn toegepast. Om je voorkeuren voor deze items bij te werken, bekijk je het item en zoek je naar de \'Volg\' opties in de zijbalk.',
+    'notifications_watched_desc' => 'Hieronder staan de items waarvoor aangepaste volgvoorkeuren zijn toegepast. Om je voorkeuren voor deze items bij te werken, bekijk je het item en zoek je naar de volgopties in de zijbalk.',
 
     'auth' => 'Toegang & Beveiliging',
     'auth_change_password' => 'Wachtwoord Wijzigen',
diff --git a/lang/nl/settings.php b/lang/nl/settings.php
index 3bba35cb5..3b4da75a9 100644
--- a/lang/nl/settings.php
+++ b/lang/nl/settings.php
@@ -43,9 +43,9 @@ return [
     'app_footer_links_label' => 'Link label',
     'app_footer_links_url' => 'Link URL',
     'app_footer_links_add' => 'Voettekst link toevoegen',
-    'app_disable_comments' => 'Reacties uitschakelen',
-    'app_disable_comments_toggle' => 'Reacties uitschakelen',
-    'app_disable_comments_desc' => 'Schakel reacties uit op alle pagina\'s in de applicatie. <br> Bestaande reacties worden niet getoond.',
+    'app_disable_comments' => 'Opmerkingen uitschakelen',
+    'app_disable_comments_toggle' => 'Opmerkingen uitschakelen',
+    'app_disable_comments_desc' => 'Schakel opmerkingen uit op alle pagina\'s in de applicatie. <br> Bestaande opmerkingen worden niet getoond.',
 
     // Color settings
     'color_scheme' => 'Kleurenschema van applicatie',
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba (Litouws)',
         'lv' => 'Latviešu Valoda (Lets)',
         'nb' => 'Norsk (Bokmål) (Noors)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski (Pools)',
diff --git a/lang/nn/common.php b/lang/nn/common.php
index 3d623dbc4..4247563e0 100644
--- a/lang/nn/common.php
+++ b/lang/nn/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Opprett',
     'update' => 'Oppdater',
     'edit' => 'Rediger',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sortér',
     'move' => 'Flytt',
     'copy' => 'Kopier',
diff --git a/lang/nn/editor.php b/lang/nn/editor.php
index 74b31748a..6128223a9 100644
--- a/lang/nn/editor.php
+++ b/lang/nn/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Heva skrift',
     'subscript' => 'Senka skrift',
     'text_color' => 'Tekstfarge',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Eigenvalgt farge',
     'remove_color' => 'Fjern farge',
     'background_color' => 'Bakgrunnsfarge',
diff --git a/lang/nn/entities.php b/lang/nn/entities.php
index 2689e1b0d..4c75d5019 100644
--- a/lang/nn/entities.php
+++ b/lang/nn/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Tilgongar',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Urørt innhold)',
     'pages_edit_switch_to_wysiwyg' => 'Bytt til WYSIWYG tekstredigering',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Angi endringslogg',
     'pages_edit_enter_changelog_desc' => 'Gi ei kort skildring av endringane dine',
     'pages_edit_enter_changelog' => 'Sjå endringslogg',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Sett inn tegning',
     'pages_md_show_preview' => 'Førhandsvisning',
     'pages_md_sync_scroll' => 'Synkroniser førehandsvisingsrulle',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Ulagra teikning funne',
     'pages_drawing_unsaved_confirm' => 'Ulagra teikninga vart funne frå ei tidligare mislykka lagring. Vil du gjenopprette og fortsette å redigere denne ulagra teikninga?',
     'pages_not_in_chapter' => 'Sida tilhøyrer ingen kapittel',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Kommentar',
     'comments' => 'Kommentarer',
     'comment_add' => 'Skriv kommentar',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Skriv en kommentar her',
-    'comment_count' => '{0} Ingen kommentarer|{1} 1 kommentar|[2,*] :count kommentarer',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Publiser kommentar',
     'comment_new' => 'Ny kommentar',
     'comment_created' => 'kommenterte :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Kommentar fjernet',
     'comment_created_success' => 'Kommentar skrevet',
     'comment_updated_success' => 'Kommentar endret',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Er du sikker på at du vil fjerne kommentaren?',
     'comment_in_reply_to' => 'Som svar til :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Her er kommentarene som er på denne siden. Kommentarer kan legges til og administreres når du ser på den lagrede siden.',
 
     // Revision
diff --git a/lang/nn/settings.php b/lang/nn/settings.php
index f71ffb204..c098ac753 100644
--- a/lang/nn/settings.php
+++ b/lang/nn/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/pl/common.php b/lang/pl/common.php
index a690cdb04..e21302928 100644
--- a/lang/pl/common.php
+++ b/lang/pl/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Utwórz',
     'update' => 'Zaktualizuj',
     'edit' => 'Edytuj',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sortuj',
     'move' => 'Przenieś',
     'copy' => 'Skopiuj',
diff --git a/lang/pl/editor.php b/lang/pl/editor.php
index 7ba9220cf..a93b5dd78 100644
--- a/lang/pl/editor.php
+++ b/lang/pl/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Indeks górny',
     'subscript' => 'Indeks dolny',
     'text_color' => 'Kolor tekstu',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Kolor niestandardowy',
     'remove_color' => 'Usuń kolor',
     'background_color' => 'Kolor tła',
diff --git a/lang/pl/entities.php b/lang/pl/entities.php
index 87a74ca21..72389500b 100644
--- a/lang/pl/entities.php
+++ b/lang/pl/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Uprawnienia',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Statyczna zawartość)',
     'pages_edit_switch_to_wysiwyg' => 'Przełącz na edytor WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Przełącz na nowy WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(Alfa testy)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Ustaw dziennik zmian',
     'pages_edit_enter_changelog_desc' => 'Opisz zmiany, które zostały wprowadzone',
     'pages_edit_enter_changelog' => 'Wyświetl dziennik zmian',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Wstaw rysunek',
     'pages_md_show_preview' => 'Pokaż podgląd',
     'pages_md_sync_scroll' => 'Synchronizuj przewijanie podglądu',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Znaleziono niezapisany rysunek',
     'pages_drawing_unsaved_confirm' => 'Znaleziono niezapisane dane rysowania z poprzedniej nieudanej próby zapisu. Czy chcesz przywrócić i kontynuować edycję tego niezapisanego rysunku?',
     'pages_not_in_chapter' => 'Strona nie została umieszczona w rozdziale',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Komentarz',
     'comments' => 'Komentarze',
     'comment_add' => 'Dodaj komentarz',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Napisz swój komentarz tutaj',
-    'comment_count' => '{0} Brak komentarzy |{1} 1 komentarz|[2,*] :count komentarzy',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Zapisz komentarz',
     'comment_new' => 'Nowy komentarz',
     'comment_created' => 'Skomentowano :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Komentarz usunięty',
     'comment_created_success' => 'Komentarz dodany',
     'comment_updated_success' => 'Komentarz zaktualizowany',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Czy na pewno chcesz usunąc ten komentarz?',
     'comment_in_reply_to' => 'W odpowiedzi na :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Oto komentarze pozostawione na tej stronie. Komentarze mogą być dodawane i zarządzane podczas przeglądania zapisanej strony.',
 
     // Revision
diff --git a/lang/pl/settings.php b/lang/pl/settings.php
index b1f0c1582..7c84ce34b 100644
--- a/lang/pl/settings.php
+++ b/lang/pl/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/pt/activities.php b/lang/pt/activities.php
index 9e458bffc..33d9e2ca6 100644
--- a/lang/pt/activities.php
+++ b/lang/pt/activities.php
@@ -30,7 +30,7 @@ return [
     // Books
     'book_create'                 => 'livro criado',
     'book_create_notification'    => 'Livro criado com sucesso',
-    'book_create_from_chapter'              => 'capítulo convertido para lívro',
+    'book_create_from_chapter'              => 'capítulo convertido para livro',
     'book_create_from_chapter_notification' => 'Capítulo convertido em livro com sucesso',
     'book_update'                 => 'livro atualizado',
     'book_update_notification'    => 'Livro atualizado com sucesso',
@@ -46,12 +46,12 @@ return [
     'bookshelf_create_from_book_notification'    => 'Livro convertido em prateleira com sucesso',
     'bookshelf_update'                 => 'estante atualizada',
     'bookshelf_update_notification'    => 'Estante atualizada com sucesso',
-    'bookshelf_delete'                 => 'prateleira excluída',
+    'bookshelf_delete'                 => 'excluiu a estante',
     'bookshelf_delete_notification'    => 'Estante eliminada com sucesso',
 
     // Revisions
-    'revision_restore' => 'revisão restaurada',
-    'revision_delete' => 'revisão eliminada',
+    'revision_restore' => 'restaurou a revisão',
+    'revision_delete' => 'eliminou a revisão',
     'revision_delete_notification' => 'Revisão eliminada com sucesso',
 
     // Favourites
@@ -62,7 +62,7 @@ return [
     'watch_update_level_notification' => 'Ver preferências atualizadas com sucesso',
 
     // Auth
-    'auth_login' => 'sessão iniciada',
+    'auth_login' => 'iniciou sessão',
     'auth_register' => 'registado como novo utilizador',
     'auth_password_reset_request' => 'pedido a redefinição da palavra-passe',
     'auth_password_reset_update' => 'redifinir palavra-passe do utilizador',
@@ -85,12 +85,12 @@ return [
     'webhook_delete_notification' => 'Webhook criado com sucesso',
 
     // Imports
-    'import_create' => 'created import',
-    'import_create_notification' => 'Import successfully uploaded',
-    'import_run' => 'updated import',
-    'import_run_notification' => 'Content successfully imported',
-    'import_delete' => 'deleted import',
-    'import_delete_notification' => 'Import successfully deleted',
+    'import_create' => 'importação criada',
+    'import_create_notification' => 'Importação carregada com sucesso',
+    'import_run' => 'importação atualizada',
+    'import_run_notification' => 'Conteúdo importado com sucesso',
+    'import_delete' => 'importação apagada',
+    'import_delete_notification' => 'Importação eliminada com sucesso',
 
     // Users
     'user_create' => 'utilizador criado',
@@ -101,11 +101,11 @@ return [
     'user_delete_notification' => 'Utilizador removido com sucesso',
 
     // API Tokens
-    'api_token_create' => 'created API token',
+    'api_token_create' => 'token API criado',
     'api_token_create_notification' => 'API token criado com sucesso',
-    'api_token_update' => 'updated API token',
+    'api_token_update' => 'token API atualizado',
     'api_token_update_notification' => 'API token atualizado com sucesso',
-    'api_token_delete' => 'deleted API token',
+    'api_token_delete' => 'token API apagado',
     'api_token_delete_notification' => 'API token atualizado com sucesso',
 
     // Roles
@@ -128,12 +128,12 @@ return [
     'comment_delete'              => 'comentário eliminado',
 
     // Sort Rules
-    'sort_rule_create' => 'created sort rule',
-    'sort_rule_create_notification' => 'Sort rule successfully created',
-    'sort_rule_update' => 'updated sort rule',
-    'sort_rule_update_notification' => 'Sort rule successfully updated',
-    'sort_rule_delete' => 'deleted sort rule',
-    'sort_rule_delete_notification' => 'Sort rule successfully deleted',
+    'sort_rule_create' => 'regra de ordenação criada',
+    'sort_rule_create_notification' => 'Regra de ordenação criada com sucesso',
+    'sort_rule_update' => 'regra de ordenação atualizada',
+    'sort_rule_update_notification' => 'Regra de ordenação atualizada com sucesso',
+    'sort_rule_delete' => 'regra de ordenação apagada',
+    'sort_rule_delete_notification' => 'Regra de ordenação apagada com sucesso',
 
     // Other
     'permissions_update'          => 'permissões atualizadas',
diff --git a/lang/pt/auth.php b/lang/pt/auth.php
index 81956161d..8807e1a87 100644
--- a/lang/pt/auth.php
+++ b/lang/pt/auth.php
@@ -91,7 +91,7 @@ return [
     'mfa_option_totp_title' => 'Aplicação móvel',
     'mfa_option_totp_desc' => 'Para usar a autenticação multi-fator, você precisa de uma aplicação móvel que suporte TOTP como o Autenticador do Google, Authy ou o autenticador Microsoft.',
     'mfa_option_backup_codes_title' => 'Códigos de Backup',
-    'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.',
+    'mfa_option_backup_codes_desc' => 'Gera um conjunto de códigos de reserva de utilização única, que deverá usar no início de sessão para verificar a sua identidade. Certifique-se de que os guarda num local seguro.',
     'mfa_gen_confirm_and_enable' => 'Confirmar e ativar',
     'mfa_gen_backup_codes_title' => 'Configuração dos Códigos de Backup',
     'mfa_gen_backup_codes_desc' => 'Armazene a lista de códigos abaixo em um lugar seguro. Ao acessar o sistema você poderá usar um dos códigos como um segundo mecanismo de autenticação.',
diff --git a/lang/pt/common.php b/lang/pt/common.php
index 3e0057002..a0d83ac1e 100644
--- a/lang/pt/common.php
+++ b/lang/pt/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Criar',
     'update' => 'Atualizar',
     'edit' => 'Editar',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Ordenar',
     'move' => 'Mover',
     'copy' => 'Copiar',
@@ -109,5 +111,5 @@ return [
     'terms_of_service' => 'Termos de Utilização',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Procurar :appName',
 ];
diff --git a/lang/pt/editor.php b/lang/pt/editor.php
index 222163102..b60f813b5 100644
--- a/lang/pt/editor.php
+++ b/lang/pt/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superior à linha',
     'subscript' => 'Inferior à linha',
     'text_color' => 'Cor do texto',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Cor personalizada',
     'remove_color' => 'Remover cor',
     'background_color' => 'Cor de fundo',
diff --git a/lang/pt/entities.php b/lang/pt/entities.php
index b9e52c1af..91fe6d90c 100644
--- a/lang/pt/entities.php
+++ b/lang/pt/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Permissões',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Conteúdo Estável)',
     'pages_edit_switch_to_wysiwyg' => 'Alternar para o editor WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Relatar Alterações',
     'pages_edit_enter_changelog_desc' => 'Digite uma breve descrição das alterações efetuadas por si',
     'pages_edit_enter_changelog' => 'Inserir Alterações',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Inserir Desenho',
     'pages_md_show_preview' => 'Mostrar pré-visualização',
     'pages_md_sync_scroll' => 'Sincronizar pré-visualização',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Encontrado um rascunho não guardado',
     'pages_drawing_unsaved_confirm' => 'Dados de um rascunho não guardado foi encontrado de um tentativa anteriormente falhada. Deseja restaurar e continuar a edição desse rascunho?',
     'pages_not_in_chapter' => 'A página não está dentro de um capítulo',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comentário',
     'comments' => 'Comentários',
     'comment_add' => 'Adicionar Comentário',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Digite aqui os seus comentários',
-    'comment_count' => '{0} Nenhum comentário|{1} 1 Comentário|[2,*] :count Comentários',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Guardar comentário',
     'comment_new' => 'Comentário Novo',
     'comment_created' => 'comentado :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comentário removido',
     'comment_created_success' => 'Comentário adicionado',
     'comment_updated_success' => 'Comentário editado',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Tem a certeza de que deseja eliminar este comentário?',
     'comment_in_reply_to' => 'Em resposta à :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Aqui estão os comentários que foram deixados nesta página. Comentários podem ser adicionados e geridos ao visualizar a página guardada.',
 
     // Revision
diff --git a/lang/pt/settings.php b/lang/pt/settings.php
index b321d2eda..0c765fb98 100644
--- a/lang/pt/settings.php
+++ b/lang/pt/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/pt_BR/common.php b/lang/pt_BR/common.php
index 4a07cfb5b..b4ff95e99 100644
--- a/lang/pt_BR/common.php
+++ b/lang/pt_BR/common.php
@@ -35,6 +35,8 @@ return [
     'create' => 'Criar',
     'update' => 'Atualizar',
     'edit' => 'Editar',
+    'archive' => 'Arquivar',
+    'unarchive' => 'Desarquivar',
     'sort' => 'Ordenar',
     'move' => 'Mover',
     'copy' => 'Copiar',
diff --git a/lang/pt_BR/editor.php b/lang/pt_BR/editor.php
index ea4035a27..9a0eb45c9 100644
--- a/lang/pt_BR/editor.php
+++ b/lang/pt_BR/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Sobrescrito',
     'subscript' => 'Subscrito',
     'text_color' => 'Cor do texto',
+    'highlight_color' => 'Cor de destaque',
     'custom_color' => 'Cor personalizada',
     'remove_color' => 'Remover cor',
     'background_color' => 'Cor de fundo',
diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php
index ba536808e..5a948c8dc 100644
--- a/lang/pt_BR/entities.php
+++ b/lang/pt_BR/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Isto irá excluir o arquivo ZIP de importação carregado e não poderá ser desfeito.',
     'import_errors' => 'Erros de importação',
     'import_errors_desc' => 'Os seguintes erros ocorreram durante a tentativa de importação:',
+    'breadcrumb_siblings_for_page' => 'Navegue pelas páginas relacionadas',
+    'breadcrumb_siblings_for_chapter' => 'Navegue pelos capítulos relacionados',
+    'breadcrumb_siblings_for_book' => 'Navegue pelos livros relacionados',
+    'breadcrumb_siblings_for_bookshelf' => 'Navegue pelas estantes relacionadas',
 
     // Permissions and restrictions
     'permissions' => 'Permissões',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Conteúdo Estável)',
     'pages_edit_switch_to_wysiwyg' => 'Alternar para o Editor WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Mudar para o novo WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(Em teste alfa)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(Em teste beta)',
     'pages_edit_set_changelog' => 'Relatar Alterações',
     'pages_edit_enter_changelog_desc' => 'Digite uma breve descrição das alterações efetuadas por você',
     'pages_edit_enter_changelog' => 'Insira Alterações',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Inserir Diagrama',
     'pages_md_show_preview' => 'Mostrar pré-visualização',
     'pages_md_sync_scroll' => 'Sincronizar pré-visualização',
+    'pages_md_plain_editor' => 'Editor de texto simples',
     'pages_drawing_unsaved' => 'Diagrama não-salvo encontrado',
     'pages_drawing_unsaved_confirm' => 'Foram encontrados dados não-salvos de uma tentativa anterior de salvar o diagrama. Você gostaria de restaurá-los e continuar editando este diagrama?',
     'pages_not_in_chapter' => 'Página não está dentro de um capítulo',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comentário',
     'comments' => 'Comentários',
     'comment_add' => 'Adicionar Comentário',
+    'comment_none' => 'Nenhum comentário para exibir',
     'comment_placeholder' => 'Digite seus comentários aqui',
-    'comment_count' => '{0} Nenhum comentário|{1} 1 Comentário|[2,*] :count Comentários',
+    'comment_thread_count' => ':count Tópico de Comentário|:count Tópicos de Comentários',
+    'comment_archived_count' => ':count Arquivado',
+    'comment_archived_threads' => 'Tópicos Arquivados',
     'comment_save' => 'Salvar comentário',
     'comment_new' => 'Novo Comentário',
     'comment_created' => 'comentado :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comentário removido',
     'comment_created_success' => 'Comentário adicionado',
     'comment_updated_success' => 'Comentário editado',
+    'comment_archive_success' => 'Comentário arquivado',
+    'comment_unarchive_success' => 'Comentário desarquivado',
+    'comment_view' => 'Ver comentário',
+    'comment_jump_to_thread' => 'Ir para o tópico',
     'comment_delete_confirm' => 'Você tem certeza de que deseja excluir este comentário?',
     'comment_in_reply_to' => 'Em resposta à :commentId',
+    'comment_reference' => 'Referência',
+    'comment_reference_outdated' => '(Desatualizado)',
     'comment_editor_explain' => 'Aqui estão os comentários que foram deixados nesta página. Comentários podem ser adicionados e gerenciados ao visualizar a página salva.',
 
     // Revision
diff --git a/lang/pt_BR/settings.php b/lang/pt_BR/settings.php
index 39007cbcc..b940e57d4 100644
--- a/lang/pt_BR/settings.php
+++ b/lang/pt_BR/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/ro/common.php b/lang/ro/common.php
index 1de631183..fe4bfe8b9 100644
--- a/lang/ro/common.php
+++ b/lang/ro/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Crează',
     'update' => 'Actualzează',
     'edit' => 'Editează',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sortează',
     'move' => 'Mută',
     'copy' => 'Copiază',
diff --git a/lang/ro/editor.php b/lang/ro/editor.php
index a61fdc6c8..951e027fe 100644
--- a/lang/ro/editor.php
+++ b/lang/ro/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Scris sus',
     'subscript' => 'Scris jos',
     'text_color' => 'Culoare text',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Culoare personalizată',
     'remove_color' => 'Elimină culoarea',
     'background_color' => 'Culoare fundal',
diff --git a/lang/ro/entities.php b/lang/ro/entities.php
index ca13d90ff..ac8c9d326 100644
--- a/lang/ro/entities.php
+++ b/lang/ro/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Permisiuni',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Conținut stabil)',
     'pages_edit_switch_to_wysiwyg' => 'Comută la editorul WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Setare jurnal modificări',
     'pages_edit_enter_changelog_desc' => 'Adaugă o scurtă descriere a modificărilor făcute',
     'pages_edit_enter_changelog' => 'Intră în jurnalul de modificări',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Inserează desen',
     'pages_md_show_preview' => 'Arată previzualizarea',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Pagina nu este într-un capitol',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comentariu',
     'comments' => 'Comentarii',
     'comment_add' => 'Adaugă comentariu',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Lasă un comentariu aici',
-    'comment_count' => '{0} Niciun comentariu|{1} 1 Comentariu [2,*] :count Comentarii',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Salvează comentariul',
     'comment_new' => 'Comentariu nou',
     'comment_created' => 'comentat :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comentariu șters',
     'comment_created_success' => 'Comentariu adăugat',
     'comment_updated_success' => 'Comentariu actualizat',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Ești sigur că vrei să ștergi acest comentariu?',
     'comment_in_reply_to' => 'Ca răspuns la :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/ro/settings.php b/lang/ro/settings.php
index c9bbaf06e..a28dca22c 100644
--- a/lang/ro/settings.php
+++ b/lang/ro/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/ru/activities.php b/lang/ru/activities.php
index 3a13ecf46..d353b7a76 100644
--- a/lang/ru/activities.php
+++ b/lang/ru/activities.php
@@ -87,7 +87,7 @@ return [
     // Imports
     'import_create' => 'created import',
     'import_create_notification' => 'Import successfully uploaded',
-    'import_run' => 'updated import',
+    'import_run' => 'обновлен импорт',
     'import_run_notification' => 'Content successfully imported',
     'import_delete' => 'deleted import',
     'import_delete_notification' => 'Import successfully deleted',
diff --git a/lang/ru/common.php b/lang/ru/common.php
index de2da532d..fa9969c32 100644
--- a/lang/ru/common.php
+++ b/lang/ru/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Создание',
     'update' => 'Обновление',
     'edit' => 'Редактировать',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Сортировать',
     'move' => 'Переместить',
     'copy' => 'Скопировать',
diff --git a/lang/ru/editor.php b/lang/ru/editor.php
index 264ab7ec3..d26f7edfe 100644
--- a/lang/ru/editor.php
+++ b/lang/ru/editor.php
@@ -29,10 +29,10 @@ return [
 
     // Toolbar
     'formats' => 'Форматы',
-    'header_large' => 'Большой',
-    'header_medium' => 'Средний',
-    'header_small' => 'Маленький',
-    'header_tiny' => 'Крошечный',
+    'header_large' => 'Крупный заголовок',
+    'header_medium' => 'Средний заголовок',
+    'header_small' => 'Небольшой заголовок',
+    'header_tiny' => 'Маленький заголовок',
     'paragraph' => 'Обычный текст',
     'blockquote' => 'Цитата',
     'inline_code' => 'Встроенный код',
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Надстрочный',
     'subscript' => 'Подстрочный',
     'text_color' => 'Цвет текста',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Пользовательский цвет',
     'remove_color' => 'Удалить цвет',
     'background_color' => 'Цвет фона',
diff --git a/lang/ru/entities.php b/lang/ru/entities.php
index 2246be043..50f061115 100644
--- a/lang/ru/entities.php
+++ b/lang/ru/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Разрешения',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => 'Полное сохранение форматирования (HTML)',
     'pages_edit_switch_to_wysiwyg' => 'Переключиться в WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(В Альфа-тестировании)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Задать список изменений',
     'pages_edit_enter_changelog_desc' => 'Введите краткое описание внесенных изменений',
     'pages_edit_enter_changelog' => 'Введите список изменений',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Вставить рисунок',
     'pages_md_show_preview' => 'Предпросмотр',
     'pages_md_sync_scroll' => 'Синхронизировать прокрутку',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Найден несохраненный чертеж',
     'pages_drawing_unsaved_confirm' => 'Несохраненные данные были найдены из предыдущей неудачной попытки сохранения рисунка. Вы хотите восстановить и продолжить редактирование несохраненного рисунка?',
     'pages_not_in_chapter' => 'Страница не находится в главе',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Комментарий',
     'comments' => 'Комментарии',
     'comment_add' => 'Комментировать',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Оставить комментарий здесь',
-    'comment_count' => '{0} Нет комментариев|{1} 1 комментарий|[2,*] :count комментария',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Сохранить комментарий',
     'comment_new' => 'Новый комментарий',
     'comment_created' => 'прокомментировал :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Комментарий удален',
     'comment_created_success' => 'Комментарий добавлен',
     'comment_updated_success' => 'Комментарий обновлен',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Удалить этот комментарий?',
     'comment_in_reply_to' => 'В ответ на :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Вот комментарии, которые были оставлены на этой странице. Комментарии могут быть добавлены и управляться при просмотре сохраненной страницы.',
 
     // Revision
diff --git a/lang/ru/settings.php b/lang/ru/settings.php
index 6cd292869..69c13e46d 100644
--- a/lang/ru/settings.php
+++ b/lang/ru/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/sk/common.php b/lang/sk/common.php
index 12828d6c2..6f116006b 100644
--- a/lang/sk/common.php
+++ b/lang/sk/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Vytvoriť',
     'update' => 'Aktualizovať',
     'edit' => 'Editovať',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Zoradiť',
     'move' => 'Presunúť',
     'copy' => 'Kopírovať',
diff --git a/lang/sk/editor.php b/lang/sk/editor.php
index 3c098e3b3..7d1781ef4 100644
--- a/lang/sk/editor.php
+++ b/lang/sk/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Horný index',
     'subscript' => 'Dolný index',
     'text_color' => 'Farba textu',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Vlastná farba',
     'remove_color' => 'Odstrániť farbu',
     'background_color' => 'Farba pozadia',
diff --git a/lang/sk/entities.php b/lang/sk/entities.php
index 347434fd0..eb5254535 100644
--- a/lang/sk/entities.php
+++ b/lang/sk/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Oprávnenia',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stabilný obsah)',
     'pages_edit_switch_to_wysiwyg' => 'Prepnite na WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Nastaviť záznam zmien',
     'pages_edit_enter_changelog_desc' => 'Zadajte krátky popis zmien, ktoré ste urobili',
     'pages_edit_enter_changelog' => 'Zadať záznam zmien',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Vložiť kresbu',
     'pages_md_show_preview' => 'Zobraziť náhľad',
     'pages_md_sync_scroll' => 'Posúvanie ukážky synchronizácie',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Stránka nie je v kapitole',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Komentár',
     'comments' => 'Komentáre',
     'comment_add' => 'Pridať komentár',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Tu zadajte svoje pripomienky',
-    'comment_count' => '{0} Bez komentárov|{1} 1 komentár|[2,3,4] :count komentáre|[5,*] :count komentárov',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Uložiť komentár',
     'comment_new' => 'Nový komentár',
     'comment_created' => 'komentované :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Komentár odstránený',
     'comment_created_success' => 'Komentár pridaný',
     'comment_updated_success' => 'Komentár aktualizovaný',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Ste si istý, že chcete odstrániť tento komentár?',
     'comment_in_reply_to' => 'Odpovedať na :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/sk/settings.php b/lang/sk/settings.php
index b07586b8d..2731fd01a 100644
--- a/lang/sk/settings.php
+++ b/lang/sk/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/sl/common.php b/lang/sl/common.php
index 29e2a0252..0309237a9 100644
--- a/lang/sl/common.php
+++ b/lang/sl/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Ustvari',
     'update' => 'Posodobi',
     'edit' => 'Uredi',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Razvrsti',
     'move' => 'Premakni',
     'copy' => 'Kopiraj',
diff --git a/lang/sl/editor.php b/lang/sl/editor.php
index c050d6b37..3afcc2312 100644
--- a/lang/sl/editor.php
+++ b/lang/sl/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Nadpisano',
     'subscript' => 'Podpisano',
     'text_color' => 'Barva besedila',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Barva po meri',
     'remove_color' => 'Odstrani barvo',
     'background_color' => 'Barva ozadja',
diff --git a/lang/sl/entities.php b/lang/sl/entities.php
index 9a7b73488..00ac99181 100644
--- a/lang/sl/entities.php
+++ b/lang/sl/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Dovoljenja',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Opiši spremembe na dokumentu',
     'pages_edit_enter_changelog_desc' => 'Vnesite kratek opis sprememb, ki ste jih naredili',
     'pages_edit_enter_changelog' => 'Vpišite vsebino sprememb',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Vstavi risbo',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Stran ni v poglavju',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Komentar',
     'comments' => 'Komentarji',
     'comment_add' => 'Dodaj komentar',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Dodaj komentar',
-    'comment_count' => '{0} Ni komentarjev|{1} 1 Komentar|[2,*] :count Komentarji',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Shrani komentar',
     'comment_new' => 'Nov kometar',
     'comment_created' => 'komentirano :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Komentar je izbrisan',
     'comment_created_success' => 'Komentar dodan',
     'comment_updated_success' => 'Komentar posodobljen',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Ste prepričani, da želite izbrisati ta komentar?',
     'comment_in_reply_to' => 'Odgovor na :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/sl/settings.php b/lang/sl/settings.php
index c49b6b79b..904978f47 100644
--- a/lang/sl/settings.php
+++ b/lang/sl/settings.php
@@ -349,6 +349,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/sq/activities.php b/lang/sq/activities.php
index fbe7e3179..9296d1759 100644
--- a/lang/sq/activities.php
+++ b/lang/sq/activities.php
@@ -85,12 +85,12 @@ return [
     'webhook_delete_notification' => 'Uebhook-u u fshi me sukses',
 
     // Imports
-    'import_create' => 'created import',
-    'import_create_notification' => 'Import successfully uploaded',
-    'import_run' => 'updated import',
-    'import_run_notification' => 'Content successfully imported',
-    'import_delete' => 'deleted import',
-    'import_delete_notification' => 'Import successfully deleted',
+    'import_create' => 'importi i krijuar',
+    'import_create_notification' => 'Importi u ngarkua me sukses',
+    'import_run' => 'Importi i përditësuar',
+    'import_run_notification' => 'Përmbajtja u importua me sukses',
+    'import_delete' => 'Importi i fshirë',
+    'import_delete_notification' => 'Importi u fshi me sukses',
 
     // Users
     'user_create' => 'krijoi përdorues',
@@ -101,11 +101,11 @@ return [
     'user_delete_notification' => 'Përdoruesi u fshi me sukses',
 
     // API Tokens
-    'api_token_create' => 'created API token',
+    'api_token_create' => 'Krijoi token API',
     'api_token_create_notification' => 'Token API u krijua me sukses',
-    'api_token_update' => 'updated API token',
+    'api_token_update' => 'Token i përditësuar i API-t',
     'api_token_update_notification' => 'Token API u përditësua me sukses',
-    'api_token_delete' => 'deleted API token',
+    'api_token_delete' => 'Fshiu tokenin API',
     'api_token_delete_notification' => 'Token API u fshi me sukses',
 
     // Roles
@@ -128,12 +128,12 @@ return [
     'comment_delete'              => 'fshiu koment',
 
     // Sort Rules
-    'sort_rule_create' => 'created sort rule',
-    'sort_rule_create_notification' => 'Sort rule successfully created',
-    'sort_rule_update' => 'updated sort rule',
-    'sort_rule_update_notification' => 'Sort rule successfully updated',
-    'sort_rule_delete' => 'deleted sort rule',
-    'sort_rule_delete_notification' => 'Sort rule successfully deleted',
+    'sort_rule_create' => 'Rregull i krijuar renditjeje',
+    'sort_rule_create_notification' => 'Rregulli i renditjes u krijua me sukses',
+    'sort_rule_update' => 'rregulli i renditjes i përditësuar',
+    'sort_rule_update_notification' => 'Rregulli i renditjes u përditësua me sukses',
+    'sort_rule_delete' => 'rregulli i renditjes është fshirë',
+    'sort_rule_delete_notification' => 'Rregulli i renditjes u fshi me sukses',
 
     // Other
     'permissions_update'          => 'përditësoi lejet',
diff --git a/lang/sq/auth.php b/lang/sq/auth.php
index e3259a961..6fdc4ee4a 100644
--- a/lang/sq/auth.php
+++ b/lang/sq/auth.php
@@ -50,34 +50,34 @@ return [
     'reset_password_sent' => 'Një link për rikthimin e fjalëkalimit do ju dërgohet në :email nëse adresa e email-it ndodhet në sistem.',
     'reset_password_success' => 'Fjalëkalimi juaj u rikthye me sukses.',
     'email_reset_subject' => 'Rikthe fjalëkalimin për :appName',
-    'email_reset_text' => 'You are receiving this email because we received a password reset request for your account.',
-    'email_reset_not_requested' => 'If you did not request a password reset, no further action is required.',
+    'email_reset_text' => 'Ju po e merrni këtë email sepse ne morëm një kërkesë për rivendosjen e fjalëkalimit për llogarinë tuaj.',
+    'email_reset_not_requested' => 'Nëse nuk keni kërkuar rivendosjen e fjalëkalimit, nuk kërkohet asnjë veprim i mëtejshëm.',
 
     // Email Confirmation
-    'email_confirm_subject' => 'Confirm your email on :appName',
-    'email_confirm_greeting' => 'Thanks for joining :appName!',
-    'email_confirm_text' => 'Please confirm your email address by clicking the button below:',
-    'email_confirm_action' => 'Confirm Email',
-    'email_confirm_send_error' => 'Email confirmation required but the system could not send the email. Contact the admin to ensure email is set up correctly.',
-    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
-    'email_confirm_resent' => 'Confirmation email resent, Please check your inbox.',
-    'email_confirm_thanks' => 'Thanks for confirming!',
-    'email_confirm_thanks_desc' => 'Please wait a moment while your confirmation is handled. If you are not redirected after 3 seconds press the "Continue" link below to proceed.',
+    'email_confirm_subject' => 'Konfirmo email-in tënd në :appName',
+    'email_confirm_greeting' => 'Faleminderit që u bashkuat me :appName!',
+    'email_confirm_text' => 'Ju lutemi konfirmoni adresën tuaj të email-it duke klikuar butonin më poshtë:',
+    'email_confirm_action' => 'Konfirmo email-in',
+    'email_confirm_send_error' => 'Kërkohet konfirmimi i email-it, por sistemi nuk mundi ta dërgonte email-in. Kontaktoni administratorin për t\'u siguruar që email-i është konfiguruar saktë.',
+    'email_confirm_success' => 'Email-i juaj është konfirmuar! Tani duhet të jeni në gjendje të hyni në sistem duke përdorur këtë adresë email-i.',
+    'email_confirm_resent' => 'Emaili i konfirmimit u ridërgua, ju lutem kontrolloni kutinë tuaj postare.',
+    'email_confirm_thanks' => 'Faleminderit që konfirmuat!',
+    'email_confirm_thanks_desc' => 'Ju lutemi prisni një moment ndërsa konfirmimi juaj përpunohet. Nëse nuk ridrejtoheni pas 3 sekondash, shtypni linkun "Vazhdo" më poshtë për të vazhduar.',
 
-    'email_not_confirmed' => 'Email Address Not Confirmed',
-    'email_not_confirmed_text' => 'Your email address has not yet been confirmed.',
-    'email_not_confirmed_click_link' => 'Please click the link in the email that was sent shortly after you registered.',
-    'email_not_confirmed_resend' => 'If you cannot find the email you can re-send the confirmation email by submitting the form below.',
-    'email_not_confirmed_resend_button' => 'Resend Confirmation Email',
+    'email_not_confirmed' => 'Adresa e email-it nuk është konfirmuar',
+    'email_not_confirmed_text' => 'Adresa juaj e email-it nuk është konfirmuar ende.',
+    'email_not_confirmed_click_link' => 'Ju lutemi klikoni linkun në emailin që ju është dërguar menjëherë pasi u regjistruat.',
+    'email_not_confirmed_resend' => 'Nëse nuk mund ta gjeni email-in, mund ta ridërgoni email-in e konfirmimit duke plotësuar formularin më poshtë.',
+    'email_not_confirmed_resend_button' => 'Ridërgo emailin e konfirmimit',
 
     // User Invite
-    'user_invite_email_subject' => 'You have been invited to join :appName!',
-    'user_invite_email_greeting' => 'An account has been created for you on :appName.',
-    'user_invite_email_text' => 'Click the button below to set an account password and gain access:',
-    'user_invite_email_action' => 'Set Account Password',
-    'user_invite_page_welcome' => 'Welcome to :appName!',
-    'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
-    'user_invite_page_confirm_button' => 'Confirm Password',
+    'user_invite_email_subject' => 'Je ftuar të bashkohesh me :appName!',
+    'user_invite_email_greeting' => 'Një llogari është krijuar për ty në :appName.',
+    'user_invite_email_text' => 'Klikoni butonin më poshtë për të vendosur një fjalëkalim llogarie dhe për të fituar akses:',
+    'user_invite_email_action' => 'Vendos fjalëkalimin e llogarisë',
+    'user_invite_page_welcome' => 'Mirë se vini në :appName!',
+    'user_invite_page_text' => 'Për të finalizuar llogarinë tuaj dhe për të fituar akses, duhet të vendosni një fjalëkalim i cili do të përdoret për t\'u kyçur në :appName në vizitat e ardhshme.',
+    'user_invite_page_confirm_button' => 'Konfirmo fjalëkalimin',
     'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
 
     // Multi-factor Authentication
diff --git a/lang/sq/common.php b/lang/sq/common.php
index b05169bb2..aa2988124 100644
--- a/lang/sq/common.php
+++ b/lang/sq/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Create',
     'update' => 'Update',
     'edit' => 'Edit',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sort',
     'move' => 'Move',
     'copy' => 'Copy',
@@ -87,8 +89,8 @@ return [
     'homepage' => 'Homepage',
     'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Profile Menu',
-    'view_profile' => 'View Profile',
-    'edit_profile' => 'Edit Profile',
+    'view_profile' => 'Shiko profilin',
+    'edit_profile' => 'Ndrysho profilin',
     'dark_mode' => 'Dark Mode',
     'light_mode' => 'Light Mode',
     'global_search' => 'Global Search',
diff --git a/lang/sq/editor.php b/lang/sq/editor.php
index 752c6f3f7..0d250e9a7 100644
--- a/lang/sq/editor.php
+++ b/lang/sq/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superscript',
     'subscript' => 'Subscript',
     'text_color' => 'Text color',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Custom color',
     'remove_color' => 'Remove color',
     'background_color' => 'Background color',
diff --git a/lang/sq/entities.php b/lang/sq/entities.php
index a74785eaa..74c50be3b 100644
--- a/lang/sq/entities.php
+++ b/lang/sq/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Set Changelog',
     'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
     'pages_edit_enter_changelog' => 'Enter Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insert Drawing',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Page is not in a chapter',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/sq/settings.php b/lang/sq/settings.php
index 82a4ade5d..81c2c0a93 100644
--- a/lang/sq/settings.php
+++ b/lang/sq/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/sr/common.php b/lang/sr/common.php
index 3eaf4fb50..e185c99f5 100644
--- a/lang/sr/common.php
+++ b/lang/sr/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Креирај',
     'update' => 'Ажурирање',
     'edit' => 'Уреди',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Разврстај',
     'move' => 'Премести',
     'copy' => 'Умножи',
diff --git a/lang/sr/editor.php b/lang/sr/editor.php
index 5db25faf3..e5052595c 100644
--- a/lang/sr/editor.php
+++ b/lang/sr/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Надскрипт',
     'subscript' => 'Субкрипт',
     'text_color' => 'Боја текста',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Боја текста',
     'remove_color' => 'Уклоните боју',
     'background_color' => 'Боја позадине',
diff --git a/lang/sr/entities.php b/lang/sr/entities.php
index 0aa531750..456a76ceb 100644
--- a/lang/sr/entities.php
+++ b/lang/sr/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Дозволе',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Set Changelog',
     'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
     'pages_edit_enter_changelog' => 'Enter Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insert Drawing',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Page is not in a chapter',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/sr/settings.php b/lang/sr/settings.php
index 090331095..b7213e905 100644
--- a/lang/sr/settings.php
+++ b/lang/sr/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/sv/common.php b/lang/sv/common.php
index 9ca2122db..2eab9bd7b 100644
--- a/lang/sv/common.php
+++ b/lang/sv/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Skapa',
     'update' => 'Uppdatera',
     'edit' => 'Redigera',
+    'archive' => 'Arkivera',
+    'unarchive' => 'Avarkivera',
     'sort' => 'Sortera',
     'move' => 'Flytta',
     'copy' => 'Kopiera',
diff --git a/lang/sv/editor.php b/lang/sv/editor.php
index a1e175ade..fbcdf8c33 100644
--- a/lang/sv/editor.php
+++ b/lang/sv/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Upphöjd',
     'subscript' => 'Nedsänkt',
     'text_color' => 'Textfärg',
+    'highlight_color' => 'Markera färg',
     'custom_color' => 'Anpassad färg',
     'remove_color' => 'Ta bort färg',
     'background_color' => 'Bakgrundsfärg',
diff --git a/lang/sv/entities.php b/lang/sv/entities.php
index e13ff95ad..08c44ff1f 100644
--- a/lang/sv/entities.php
+++ b/lang/sv/entities.php
@@ -17,7 +17,7 @@ return [
     'recent_activity' => 'Aktivitet',
     'create_now' => 'Skapa en nu',
     'revisions' => 'Revisioner',
-    'meta_revision' => 'Revisions #:revisionCount',
+    'meta_revision' => 'Revision #:revisionCount',
     'meta_created' => 'Skapad :timeLength',
     'meta_created_name' => 'Skapad :timeLength av :user',
     'meta_updated' => 'Uppdaterad :timeLength',
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Detta kommer att ta bort den uppladdade ZIP-baserade importfilen och kan inte ångras.',
     'import_errors' => 'Importfel',
     'import_errors_desc' => 'Följande fel inträffade under importförsöket:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Rättigheter',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stabilt innehåll)',
     'pages_edit_switch_to_wysiwyg' => 'Växla till WYSIWYG-redigerare',
     'pages_edit_switch_to_new_wysiwyg' => 'Växla till ny WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(Under alfatestning)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Beskriv dina ändringar',
     'pages_edit_enter_changelog_desc' => 'Ange en kort beskrivning av de ändringar du har gjort',
     'pages_edit_enter_changelog' => 'Ändringslogg',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Infoga teckning',
     'pages_md_show_preview' => 'Visa förhandsgranskning',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Osparad ritning hittades',
     'pages_drawing_unsaved_confirm' => 'Osparade ritningsdata hittades från ett tidigare misslyckat sparförsök. Vill du återställa och fortsätta redigera den osparade ritningen?',
     'pages_not_in_chapter' => 'Sidan ligger inte i något kapitel',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Kommentar',
     'comments' => 'Kommentarer',
     'comment_add' => 'Lägg till kommentar',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Lämna en kommentar här',
-    'comment_count' => '{0} Inga kommentarer|{1} 1 kommentar|[2,*] :count kommentarer',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Spara kommentar',
     'comment_new' => 'Ny kommentar',
     'comment_created' => 'kommenterade :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Kommentar borttagen',
     'comment_created_success' => 'Kommentaren har sparats',
     'comment_updated_success' => 'Kommentaren har uppdaterats',
+    'comment_archive_success' => 'Arkivera kommentar',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'Visa kommentar',
+    'comment_jump_to_thread' => 'Hoppa till tråd',
     'comment_delete_confirm' => 'Är du säker på att du vill ta bort den här kommentaren?',
     'comment_in_reply_to' => 'Som svar på :commentId',
+    'comment_reference' => 'Referens',
+    'comment_reference_outdated' => '(Utdaterad)',
     'comment_editor_explain' => 'Här är kommentarer som lämnats på denna sida. Kommentarer kan läggas till och hanteras när den sparade sidan visas.',
 
     // Revision
diff --git a/lang/sv/errors.php b/lang/sv/errors.php
index d65b7d0a0..4505dfa0b 100644
--- a/lang/sv/errors.php
+++ b/lang/sv/errors.php
@@ -37,20 +37,20 @@ return [
     'social_driver_not_found' => 'Drivrutinen för den här tjänsten hittades inte',
     'social_driver_not_configured' => 'Dina inställningar för :socialAccount är inte korrekta.',
     'invite_token_expired' => 'Denna inbjudningslänk har löpt ut. Du kan istället försöka återställa ditt kontos lösenord.',
-    'login_user_not_found' => 'A user for this action could not be found.',
+    'login_user_not_found' => 'En användare för denna åtgärd kunde inte hittas.',
 
     // System
     'path_not_writable' => 'Kunde inte ladda upp till sökvägen :filePath. Kontrollera att webbservern har skrivåtkomst.',
     'cannot_get_image_from_url' => 'Kan inte hämta bild från :url',
     'cannot_create_thumbs' => 'Servern kan inte skapa miniatyrer. Kontrollera att du har PHPs GD-tillägg aktiverat.',
     'server_upload_limit' => 'Servern tillåter inte så här stora filer. Prova en mindre fil.',
-    'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.',
+    'server_post_limit' => 'Servern kan inte ta emot den angivna mängden data. Försök igen med mindre data eller en mindre fil.',
     'uploaded'  => 'Servern tillåter inte så här stora filer. Prova en mindre fil.',
 
     // Drawing & Images
     'image_upload_error' => 'Ett fel inträffade vid uppladdningen',
     'image_upload_type_error' => 'Filtypen du försöker ladda upp är ogiltig',
-    'image_upload_replace_type' => 'Image file replacements must be of the same type',
+    'image_upload_replace_type' => 'Bilder som skall ersättas måste vara av samma filtyp',
     'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.',
     'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.',
     'image_gallery_thumbnail_memory_limit' => 'Misslyckades att skapa galleriminiatyrer på grund av otillräckliga systemresurser.',
@@ -107,7 +107,7 @@ return [
 
     // Import
     'import_zip_cant_read' => 'Kunde inte läsa ZIP-filen.',
-    'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
+    'import_zip_cant_decode_data' => 'Kunde inte hitta och avkoda ZIP data.json innehåll.',
     'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
     'import_validation_failed' => 'ZIP-filen kunde inte valideras med fel:',
     'import_zip_failed_notification' => 'Det gick inte att importera ZIP-fil.',
diff --git a/lang/sv/settings.php b/lang/sv/settings.php
index 4af72c7f7..8f391d3d7 100644
--- a/lang/sv/settings.php
+++ b/lang/sv/settings.php
@@ -91,16 +91,16 @@ return [
     'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.',
     'sort_rule_operations' => 'Sort Operations',
     'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.',
-    'sort_rule_available_operations' => 'Available Operations',
+    'sort_rule_available_operations' => 'Tillgängliga åtgärder',
     'sort_rule_available_operations_empty' => 'No operations remaining',
     'sort_rule_configured_operations' => 'Configured Operations',
     'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list',
     'sort_rule_op_asc' => '(Asc)',
     'sort_rule_op_desc' => '(Desc)',
-    'sort_rule_op_name' => 'Name - Alphabetical',
-    'sort_rule_op_name_numeric' => 'Name - Numeric',
-    'sort_rule_op_created_date' => 'Created Date',
-    'sort_rule_op_updated_date' => 'Updated Date',
+    'sort_rule_op_name' => 'Namn - Alfabetisk ordning',
+    'sort_rule_op_name_numeric' => 'Namn - Numerisk ordning',
+    'sort_rule_op_created_date' => 'Datum skapat',
+    'sort_rule_op_updated_date' => 'Datum uppdaterat',
     'sort_rule_op_chapters_first' => 'Chapters First',
     'sort_rule_op_chapters_last' => 'Chapters Last',
 
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/tk/common.php b/lang/tk/common.php
index b05169bb2..06a9e855c 100644
--- a/lang/tk/common.php
+++ b/lang/tk/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Create',
     'update' => 'Update',
     'edit' => 'Edit',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sort',
     'move' => 'Move',
     'copy' => 'Copy',
diff --git a/lang/tk/editor.php b/lang/tk/editor.php
index 752c6f3f7..0d250e9a7 100644
--- a/lang/tk/editor.php
+++ b/lang/tk/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Superscript',
     'subscript' => 'Subscript',
     'text_color' => 'Text color',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Custom color',
     'remove_color' => 'Remove color',
     'background_color' => 'Background color',
diff --git a/lang/tk/entities.php b/lang/tk/entities.php
index a74785eaa..74c50be3b 100644
--- a/lang/tk/entities.php
+++ b/lang/tk/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
     'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Set Changelog',
     'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
     'pages_edit_enter_changelog' => 'Enter Changelog',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Insert Drawing',
     'pages_md_show_preview' => 'Show preview',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Page is not in a chapter',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Comment',
     'comments' => 'Comments',
     'comment_add' => 'Add Comment',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Save Comment',
     'comment_new' => 'New Comment',
     'comment_created' => 'commented :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
     'comment_in_reply_to' => 'In reply to :commentId',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
 
     // Revision
diff --git a/lang/tk/settings.php b/lang/tk/settings.php
index 82a4ade5d..81c2c0a93 100644
--- a/lang/tk/settings.php
+++ b/lang/tk/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/tr/common.php b/lang/tr/common.php
index 3e416d37b..5b5e75387 100644
--- a/lang/tr/common.php
+++ b/lang/tr/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Oluştur',
     'update' => 'Güncelle',
     'edit' => 'Düzenle',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sırala',
     'move' => 'Taşı',
     'copy' => 'Kopyala',
diff --git a/lang/tr/editor.php b/lang/tr/editor.php
index 6bb1d5db8..c020c82fd 100644
--- a/lang/tr/editor.php
+++ b/lang/tr/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Üst simge',
     'subscript' => 'Alt simge',
     'text_color' => 'Metin rengi',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Özel renk',
     'remove_color' => 'Rengi kaldır',
     'background_color' => 'Arka plan rengi',
diff --git a/lang/tr/entities.php b/lang/tr/entities.php
index 4e98df9b3..39feb7e39 100644
--- a/lang/tr/entities.php
+++ b/lang/tr/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'İzinler',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Kararlı İçerik)',
     'pages_edit_switch_to_wysiwyg' => 'WYSIWYG düzenleyiciye geç',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'Değişim Günlüğünü Ayarla',
     'pages_edit_enter_changelog_desc' => 'Yaptığınız değişiklikler hakkında kısa bir açıklama girin',
     'pages_edit_enter_changelog' => 'Değişim Günlüğünü Yazın',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Çizim Ekle',
     'pages_md_show_preview' => 'Önizlemeyi göster',
     'pages_md_sync_scroll' => 'Sync preview scroll',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Unsaved Drawing Found',
     'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
     'pages_not_in_chapter' => 'Bu sayfa, bir bölüme ait değil',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Yorum',
     'comments' => 'Yorumlar',
     'comment_add' => 'Yorum Ekle',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Buraya bir yorum yazın',
-    'comment_count' => '{0} Yorum Yapılmamış|{1} 1 Yorum|[2,*] :count Yorum',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Yorumu Gönder',
     'comment_new' => 'Yeni Yorum',
     'comment_created' => ':createDiff yorum yaptı',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Yorum silindi',
     'comment_created_success' => 'Yorum gönderildi',
     'comment_updated_success' => 'Yorum güncellendi',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Bu yorumu silmek istediğinize emin misiniz?',
     'comment_in_reply_to' => ':commentId yorumuna yanıt olarak',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'İşte bu sayfaya bırakılan yorumlar. Kaydedilen sayfayı görüntülerken yorumlar eklenebilir ve yönetilebilir.',
 
     // Revision
diff --git a/lang/tr/settings.php b/lang/tr/settings.php
index 11ab1b6df..1eb15d9d4 100644
--- a/lang/tr/settings.php
+++ b/lang/tr/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/uk/common.php b/lang/uk/common.php
index aed4880f1..d71f57162 100644
--- a/lang/uk/common.php
+++ b/lang/uk/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Створити',
     'update' => 'Оновити',
     'edit' => 'Редагувати',
+    'archive' => 'Архів',
+    'unarchive' => 'Розархівувати',
     'sort' => 'Сортувати',
     'move' => 'Перемістити',
     'copy' => 'Копіювати',
diff --git a/lang/uk/editor.php b/lang/uk/editor.php
index f887b3203..b9024e052 100644
--- a/lang/uk/editor.php
+++ b/lang/uk/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Верхній індекс',
     'subscript' => 'Нижній індекс',
     'text_color' => 'Колір тексту',
+    'highlight_color' => 'Колір підсвічування',
     'custom_color' => 'Власний колір',
     'remove_color' => 'Видалити колір',
     'background_color' => 'Колір фону',
diff --git a/lang/uk/entities.php b/lang/uk/entities.php
index 577fd1296..cf79b530c 100644
--- a/lang/uk/entities.php
+++ b/lang/uk/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'Це видалить завантажений імпорт файлу ZIP, і його не можна буде скасувати.',
     'import_errors' => 'Помилки імпорту',
     'import_errors_desc' => 'Під час спроби імпорту відбулися наступні помилки:',
+    'breadcrumb_siblings_for_page' => 'Переглянути інші сторінки',
+    'breadcrumb_siblings_for_chapter' => 'Переглянути інші розділи',
+    'breadcrumb_siblings_for_book' => 'Переглянути інші книги',
+    'breadcrumb_siblings_for_bookshelf' => 'Переглянути інші полиці',
 
     // Permissions and restrictions
     'permissions' => 'Дозволи',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Стабілізувати вміст)',
     'pages_edit_switch_to_wysiwyg' => 'Змінити редактор на WYSIWYG',
     'pages_edit_switch_to_new_wysiwyg' => 'Перейти на новий WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(В альфа-тестуванні)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(На бета-тестування)',
     'pages_edit_set_changelog' => 'Встановити журнал змін',
     'pages_edit_enter_changelog_desc' => 'Введіть короткий опис внесених вами змін',
     'pages_edit_enter_changelog' => 'Введіть список змін',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Вставити малюнок',
     'pages_md_show_preview' => 'Показати попередній перегляд',
     'pages_md_sync_scroll' => 'Синхронізація прокручування попереднього перегляду',
+    'pages_md_plain_editor' => 'Текстовий редактор',
     'pages_drawing_unsaved' => 'Знайдено незбережену чернетку',
     'pages_drawing_unsaved_confirm' => 'Незбережені чернетки були знайдені з попередньої спроби зберегти звіт. Хочете відновити і продовжити редагування цієї чернетки?',
     'pages_not_in_chapter' => 'Сторінка не знаходиться в розділі',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Коментар',
     'comments' => 'Коментарі',
     'comment_add' => 'Додати коментар',
+    'comment_none' => 'Немає коментарів для відображення',
     'comment_placeholder' => 'Залиште коментар тут',
-    'comment_count' => '{0} Без коментарів|{1} 1 коментар|[2,*] :count коментарі(в)',
+    'comment_thread_count' => ':count тема коментарів|:count теми коментарів',
+    'comment_archived_count' => 'Архівовано :count',
+    'comment_archived_threads' => 'Архівовані теми',
     'comment_save' => 'Зберегти коментар',
     'comment_new' => 'Новий коментар',
     'comment_created' => 'прокоментував :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Коментар видалено',
     'comment_created_success' => 'Коментар додано',
     'comment_updated_success' => 'Коментар оновлено',
+    'comment_archive_success' => 'Коментар архівовано',
+    'comment_unarchive_success' => 'Коментар розархівовано',
+    'comment_view' => 'Переглянути коментар',
+    'comment_jump_to_thread' => 'Перейти до теми',
     'comment_delete_confirm' => 'Ви впевнені, що хочете видалити цей коментар?',
     'comment_in_reply_to' => 'У відповідь на :commentId',
+    'comment_reference' => 'По посиланню',
+    'comment_reference_outdated' => '(Застарілий)',
     'comment_editor_explain' => 'Ось коментарі, які залишилися на цій сторінці. Коментарі можна додати та керовані при перегляді збереженої сторінки.',
 
     // Revision
diff --git a/lang/uk/settings.php b/lang/uk/settings.php
index 5ab70d1ae..3798f1b60 100644
--- a/lang/uk/settings.php
+++ b/lang/uk/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/uz/common.php b/lang/uz/common.php
index fcbb2ea84..2c1788bee 100644
--- a/lang/uz/common.php
+++ b/lang/uz/common.php
@@ -30,6 +30,8 @@ return [
     'create' => 'Yaratish',
     'update' => 'Yangilash',
     'edit' => 'Tahrirlash',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Saralash',
     'move' => 'Ko‘chirish',
     'copy' => 'Nusxalash',
diff --git a/lang/uz/editor.php b/lang/uz/editor.php
index 905855b4c..a7f91d769 100644
--- a/lang/uz/editor.php
+++ b/lang/uz/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => 'Yuqori yozuv',
     'subscript' => 'Subscript',
     'text_color' => 'Matn rangi',
+    'highlight_color' => 'Highlight color',
     'custom_color' => 'Maxsus rang',
     'remove_color' => 'Rangni olib tashlash',
     'background_color' => 'Fon rangi',
diff --git a/lang/uz/entities.php b/lang/uz/entities.php
index 24f5a1acf..e35a485b6 100644
--- a/lang/uz/entities.php
+++ b/lang/uz/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
     'import_errors' => 'Import Errors',
     'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => 'Huquqlar',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(Barqaror tarkib)',
     'pages_edit_switch_to_wysiwyg' => 'WYSIWYG muharririga o\'ting',
     'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
     'pages_edit_set_changelog' => 'O\'zgarishlar jurnalini o\'rnating',
     'pages_edit_enter_changelog_desc' => 'Siz kiritgan o\'zgarishlarning qisqacha tavsifini kiriting',
     'pages_edit_enter_changelog' => 'O\'zgarishlar jurnaliga kiring',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => 'Chizma kiritish',
     'pages_md_show_preview' => 'Ko‘rish',
     'pages_md_sync_scroll' => 'Sinxronizatsiyani oldindan ko\'rish aylantirish',
+    'pages_md_plain_editor' => 'Plaintext editor',
     'pages_drawing_unsaved' => 'Saqlanmagan chizma topildi',
     'pages_drawing_unsaved_confirm' => 'Saqlanmagan chizma maʼlumotlari avvalgi muvaffaqiyatsiz chizmani saqlash urinishidan topildi. Ushbu saqlanmagan chizmani qayta tiklash va tahrirlashni davom ettirmoqchimisiz?',
     'pages_not_in_chapter' => 'Sahifa bir bobda emas',
@@ -392,8 +397,11 @@ return [
     'comment' => 'Izoh',
     'comments' => 'Izohlar',
     'comment_add' => 'Fikr qo\'shish',
+    'comment_none' => 'No comments to display',
     'comment_placeholder' => 'Bu yerda fikr qoldiring',
-    'comment_count' => '{0} Sharhlar yoʻq|{1} 1 ta fikr|[2,*] :sharhlar soni',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count Archived',
+    'comment_archived_threads' => 'Archived Threads',
     'comment_save' => 'Fikrni saqlash',
     'comment_new' => 'Yangi izoh',
     'comment_created' => 'izoh berdi:createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => 'Fikr o‘chirildi',
     'comment_created_success' => 'Fikr qo\'shildi',
     'comment_updated_success' => 'Fikr yangilandi',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
+    'comment_view' => 'View comment',
+    'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Haqiqatan ham bu fikrni oʻchirib tashlamoqchimisiz?',
     'comment_in_reply_to' => ':commentId ga javoban',
+    'comment_reference' => 'Reference',
+    'comment_reference_outdated' => '(Outdated)',
     'comment_editor_explain' => 'Mana shu sahifada qolgan izohlar. Saqlangan sahifani ko\'rishda sharhlar qo\'shilishi va boshqarilishi mumkin.',
 
     // Revision
diff --git a/lang/uz/settings.php b/lang/uz/settings.php
index 873642c2a..83fedc569 100644
--- a/lang/uz/settings.php
+++ b/lang/uz/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/vi/activities.php b/lang/vi/activities.php
index ef9cf7203..00ec6ce04 100644
--- a/lang/vi/activities.php
+++ b/lang/vi/activities.php
@@ -59,7 +59,7 @@ return [
     'favourite_remove_notification' => '":name" đã được gỡ khỏi danh sách yêu thích của bạn',
 
     // Watching
-    'watch_update_level_notification' => 'Watch preferences successfully updated',
+    'watch_update_level_notification' => 'Đã cập nhật tùy chọn xem thành công',
 
     // Auth
     'auth_login' => 'đăng nhập',
@@ -85,12 +85,12 @@ return [
     'webhook_delete_notification' => 'Webhook đã được xóa thành công',
 
     // Imports
-    'import_create' => 'created import',
-    'import_create_notification' => 'Import successfully uploaded',
-    'import_run' => 'updated import',
-    'import_run_notification' => 'Content successfully imported',
-    'import_delete' => 'deleted import',
-    'import_delete_notification' => 'Import successfully deleted',
+    'import_create' => 'đã tạo nhập',
+    'import_create_notification' => 'Tải lên nhập thành công',
+    'import_run' => 'đã nhập cập nhật',
+    'import_run_notification' => 'Nội dung đã được nhập thành công',
+    'import_delete' => 'Đã xóa nhập',
+    'import_delete_notification' => 'Nhập đã được xóa thành công',
 
     // Users
     'user_create' => 'đã tạo người dùng',
@@ -101,17 +101,17 @@ return [
     'user_delete_notification' => 'Người dùng đã được xóa thành công',
 
     // API Tokens
-    'api_token_create' => 'created API token',
-    'api_token_create_notification' => 'API token successfully created',
-    'api_token_update' => 'updated API token',
-    'api_token_update_notification' => 'API token successfully updated',
-    'api_token_delete' => 'deleted API token',
-    'api_token_delete_notification' => 'API token successfully deleted',
+    'api_token_create' => 'Đã tạo Token API ',
+    'api_token_create_notification' => 'Token API  đã tạo thành công',
+    'api_token_update' => 'Đã cập nhật token API ',
+    'api_token_update_notification' => 'Token API  đã cập nhật thành công',
+    'api_token_delete' => 'Đã xóa token API',
+    'api_token_delete_notification' => 'Đã xóa token API thành công',
 
     // Roles
-    'role_create' => 'created role',
+    'role_create' => 'Đã tạo vai trò',
     'role_create_notification' => 'Vai trò mới đã được tạo thành công',
-    'role_update' => 'updated role',
+    'role_update' => 'Vai trò đã cập nhật',
     'role_update_notification' => 'Vai trò đã được cập nhật thành công',
     'role_delete' => 'đã xóa vai trò',
     'role_delete_notification' => 'Vai trò đã được xóa thành công',
@@ -128,12 +128,12 @@ return [
     'comment_delete'              => 'đã xóa bình luận',
 
     // Sort Rules
-    'sort_rule_create' => 'created sort rule',
-    'sort_rule_create_notification' => 'Sort rule successfully created',
-    'sort_rule_update' => 'updated sort rule',
-    'sort_rule_update_notification' => 'Sort rule successfully updated',
-    'sort_rule_delete' => 'deleted sort rule',
-    'sort_rule_delete_notification' => 'Sort rule successfully deleted',
+    'sort_rule_create' => '',
+    'sort_rule_create_notification' => '',
+    'sort_rule_update' => 'xóa quy tắc sắp xếp',
+    'sort_rule_update_notification' => 'Đã cập nhật quy tắc sắp xếp thành công',
+    'sort_rule_delete' => 'xóa quy tắc sắp xếp',
+    'sort_rule_delete_notification' => 'Đã xóa quy tắc sắp xếp thành công',
 
     // Other
     'permissions_update'          => 'các quyền đã được cập nhật',
diff --git a/lang/vi/auth.php b/lang/vi/auth.php
index a13b8993e..d53d2cf4c 100644
--- a/lang/vi/auth.php
+++ b/lang/vi/auth.php
@@ -66,9 +66,9 @@ return [
 
     'email_not_confirmed' => 'Địa chỉ email chưa được xác nhận',
     'email_not_confirmed_text' => 'Địa chỉ email của bạn hiện vẫn chưa được xác nhận.',
-    'email_not_confirmed_click_link' => 'Vui lòng bấm vào liên kết trong mail được gửi trong thời gian ngắn ngay sau khi bạn đăng kí.',
-    'email_not_confirmed_resend' => 'Nếu bạn không tìm thấy email bạn có thể yêu cầu gửi lại email xác nhận bằng cách gửi mẫu dưới đây.',
-    'email_not_confirmed_resend_button' => 'Gửi lại email xác nhận',
+    'email_not_confirmed_click_link' => 'Vui lòng bấm vào liên kết trong email được gửi trong thời gian ngắn ngay sau khi bạn đăng kí.',
+    'email_not_confirmed_resend' => 'Nếu bạn không tìm thấy e-mail bạn có thể yêu cầu gửi lại e-mail xác nhận bằng cách gửi mẫu dưới đây.',
+    'email_not_confirmed_resend_button' => 'Gửi lại e-mail xác nhận',
 
     // User Invite
     'user_invite_email_subject' => 'Bạn được mời tham gia :appName!',
@@ -91,7 +91,7 @@ return [
     'mfa_option_totp_title' => 'Ứng dụng di động',
     'mfa_option_totp_desc' => 'Để sử dụng xác thực đa lớp bạn cần ưng dụng trên điện thoại có hỗ trợ TOTP như Google Authenticator, Authy hoặc Microsoft Authenticator.',
     'mfa_option_backup_codes_title' => 'Mã dự phòng',
-    'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.',
+    'mfa_option_backup_codes_desc' => 'Tạo một bộ mã dự phòng sử dụng một lần mà bạn sẽ nhập khi đăng nhập để xác minh danh tính của mình. Hãy đảm bảo cất giữ những thứ này ở nơi an toàn và bảo mật.',
     'mfa_gen_confirm_and_enable' => 'Xác nhận và Mở',
     'mfa_gen_backup_codes_title' => 'Cài đặt Mã dự phòng',
     'mfa_gen_backup_codes_desc' => 'Lưu trữ các mã dưới đây ở một nơi an toàn. Khi truy cập vào hệ thống bạn sẽ có thể sử dụng được một trong các đoạn mã đó như là một phương thức xác thực dự phòng.',
diff --git a/lang/vi/common.php b/lang/vi/common.php
index 16929a2fb..5035788f8 100644
--- a/lang/vi/common.php
+++ b/lang/vi/common.php
@@ -20,7 +20,7 @@ return [
     'description' => 'Mô tả',
     'role' => 'Vai trò',
     'cover_image' => 'Ảnh bìa',
-    'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.',
+    'cover_image_description' => 'Hình ảnh này phải có kích thước khoảng 440x250px mặc dù nó sẽ được thu nhỏ & cắt xén linh hoạt để phù hợp với giao diện người dùng trong các trường hợp khác nhau theo yêu cầu, do đó kích thước thực tế để hiển thị sẽ khác nhau.',
 
     // Actions
     'actions' => 'Hành động',
@@ -30,6 +30,8 @@ return [
     'create' => 'Tạo',
     'update' => 'Cập nhật',
     'edit' => 'Sửa',
+    'archive' => 'Lưu trữ',
+    'unarchive' => 'Bỏ lưu trữ',
     'sort' => 'Sắp xếp',
     'move' => 'Di chuyển',
     'copy' => 'Sao chép',
@@ -42,7 +44,7 @@ return [
     'remove' => 'Xóa bỏ',
     'add' => 'Thêm',
     'configure' => 'Cấu hình',
-    'manage' => 'Manage',
+    'manage' => 'Quản lý',
     'fullscreen' => 'Toàn màn hình',
     'favourite' => 'Yêu thích',
     'unfavourite' => 'Bỏ yêu thích',
@@ -52,7 +54,7 @@ return [
     'filter_clear' => 'Xóa bộ lọc',
     'download' => 'Tải về',
     'open_in_tab' => 'Mở trong thẻ mới',
-    'open' => 'Open',
+    'open' => 'Mở',
 
     // Sort Options
     'sort_options' => 'Tùy Chọn Sắp Xếp',
@@ -100,7 +102,8 @@ return [
     'tab_content_label' => 'Tab: Hiển thị nội dung chính',
 
     // Email Content
-    'email_action_help' => 'Nếu bạn đang có vấn đề trong việc bấm nút ":actionText", sao chép và dán địa chỉ URL dưới đây vào trình duyệt web:',
+    'email_action_help' => 'Nếu bạn gặp sự cố khi nhấp vào nút ": actionText", hãy sao chép và dán URL bên dưới
+vào trình duyệt web của bạn:',
     'email_rights' => 'Bản quyền đã được bảo hộ',
 
     // Footer Link Options
@@ -109,5 +112,5 @@ return [
     'terms_of_service' => 'Điều khoản Dịch vụ',
 
     // OpenSearch
-    'opensearch_description' => 'Search :appName',
+    'opensearch_description' => 'Tìm kiếm :appName',
 ];
diff --git a/lang/vi/components.php b/lang/vi/components.php
index 802d09d0b..eb17b4acd 100644
--- a/lang/vi/components.php
+++ b/lang/vi/components.php
@@ -34,8 +34,8 @@ return [
     'image_delete_success' => 'Ảnh đã được xóa thành công',
     'image_replace' => 'Thay thế hình ảnh',
     'image_replace_success' => 'Đã cập nhật thành công tệp hình ảnh',
-    'image_rebuild_thumbs' => 'Regenerate Size Variations',
-    'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!',
+    'image_rebuild_thumbs' => 'Tái tạo các biến thể kích thước',
+    'image_rebuild_thumbs_success' => 'Các biến thể kích thước hình ảnh được xây dựng lại thành công!',
 
     // Code Editor
     'code_editor' => 'Sửa Mã',
diff --git a/lang/vi/editor.php b/lang/vi/editor.php
index ed5246106..fd3dbdfb1 100644
--- a/lang/vi/editor.php
+++ b/lang/vi/editor.php
@@ -10,15 +10,15 @@ return [
     'general' => 'Tổng quát',
     'advanced' => 'Nâng cao',
     'none' => 'Không',
-    'cancel' => 'Huỷ',
+    'cancel' => 'Hủy',
     'save' => 'Lưu',
     'close' => 'Đóng',
-    'apply' => 'Apply',
+    'apply' => 'Áp dụng',
     'undo' => 'Hoàn tác',
     'redo' => 'Làm lại',
     'left' => 'Bên trái',
     'center' => 'Chính giữa',
-    'right' => 'Bên phải',
+    'right' => 'Bên phải',
     'top' => 'Trên cùng',
     'middle' => 'Giữa',
     'bottom' => 'Dưới cùng',
@@ -30,7 +30,7 @@ return [
     // Toolbar
     'formats' => 'Định dạng',
     'header_large' => 'Tiêu đề lớn',
-    'header_medium' => 'Tiêu đề trung binh',
+    'header_medium' => 'Tiêu đề trung bình',
     'header_small' => 'Tiêu đề nhỏ',
     'header_tiny' => 'Tiêu đề cực nhỏ',
     'paragraph' => 'Đoạn văn',
@@ -39,17 +39,18 @@ return [
     'callouts' => 'Chú thích',
     'callout_information' => 'Thông tin',
     'callout_success' => 'Thành công',
-    'callout_warning' => 'Cánh báo',
+    'callout_warning' => 'Cảnh báo',
     'callout_danger' => 'Nguy hiểm',
     'bold' => 'In đậm',
     'italic' => 'In nghiêng',
     'underline' => 'Gạch chân',
     'strikethrough' => 'Gạch ngang',
     'superscript' => 'Chỉ số trên',
-    'subscript' => 'Đăng ký',
+    'subscript' => 'Chỉ số dưới',
     'text_color' => 'Màu chữ',
+    'highlight_color' => 'Màu đánh dấu',
     'custom_color' => 'Màu tùy chỉnh',
-    'remove_color' => 'Xoá màu',
+    'remove_color' => 'Xóa màu',
     'background_color' => 'Màu nền',
     'align_left' => 'Canh lề trái',
     'align_center' => 'Căn giữa',
@@ -68,7 +69,7 @@ return [
     'insert_horizontal_line' => 'Chèn đường ngang',
     'insert_code_block' => 'Chèn khối mã',
     'edit_code_block' => 'Chỉnh sửa khối mã',
-    'insert_drawing' => 'Chèn/chỉnh sửa bản vẻ',
+    'insert_drawing' => 'Chèn/chỉnh sửa bản vẽ',
     'drawing_manager' => 'Quản lý hình vẽ',
     'insert_media' => 'Chèn/chỉnh sửa media',
     'insert_media_title' => 'Chèn/chỉnh sửa media',
@@ -76,15 +77,15 @@ return [
     'source_code' => 'Mã nguồn',
     'source_code_title' => 'Mã Nguồn',
     'fullscreen' => 'Toàn màn hình',
-    'image_options' => 'Tuỳ chọn hình ảnh',
+    'image_options' => 'Tùy chọn hình ảnh',
 
     // Tables
     'table_properties' => 'Thuộc tính bảng',
     'table_properties_title' => 'Thuộc tính bảng',
     'delete_table' => 'Xóa bảng',
-    'table_clear_formatting' => 'Clear table formatting',
-    'resize_to_contents' => 'Resize to contents',
-    'row_header' => 'Row header',
+    'table_clear_formatting' => 'Xóa định dạng bảng',
+    'resize_to_contents' => 'Thay đổi kích thước theo nội dung',
+    'row_header' => 'Tiêu đề hàng',
     'insert_row_before' => 'Chèn thêm hàng ở trên',
     'insert_row_after' => 'Chèn thêm hàng ở dưới',
     'delete_row' => 'Xóa hàng',
@@ -111,71 +112,71 @@ return [
     'border_color' => 'Màu viền',
     'row_properties' => 'Đặt thuộc tính hàng',
     'row_properties_title' => 'Đặt thuộc tính hàng',
-    'cut_row' => 'Tách cột',
-    'copy_row' => 'Sao chép cột',
-    'paste_row_before' => 'Dán cột vào bên trái',
-    'paste_row_after' => 'Dán cột vào bên phải',
+    'cut_row' => 'Cắt hàng',
+    'copy_row' => 'Sao chép hàng',
+    'paste_row_before' => 'Dán hàng vào bên trên',
+    'paste_row_after' => 'Dán hàng vào bên dưới',
     'row_type' => 'Kiểu hàng',
     'row_type_header' => 'Tiêu đề',
     'row_type_body' => 'Nội dung',
-    'row_type_footer' => 'Cuối trang',
+    'row_type_footer' => 'Chân trang',
     'alignment' => 'Canh lề',
     'cut_column' => 'Cắt cột',
     'copy_column' => 'Sao chép cột',
-    'paste_column_before' => 'Thêm cột phía trước',
-    'paste_column_after' => 'Thêm cột phía sau',
-    'cell_padding' => 'Cell padding',
-    'cell_spacing' => 'Cell spacing',
+    'paste_column_before' => 'Dán cột vào bên trái',
+    'paste_column_after' => 'Dán cột vào bên phải',
+    'cell_padding' => 'Đệm ô',
+    'cell_spacing' => 'Khoảng cách ô',
     'caption' => 'Chú thích',
     'show_caption' => 'Hiện chú thích',
     'constrain' => 'Ràng buộc các thuộc tính',
-    'cell_border_solid' => 'Khối',
-    'cell_border_dotted' => 'Điểm chấm',
-    'cell_border_dashed' => 'Dashed',
-    'cell_border_double' => 'Double',
-    'cell_border_groove' => 'Groove',
-    'cell_border_ridge' => 'Ridge',
-    'cell_border_inset' => 'Inset',
-    'cell_border_outset' => 'Outset',
-    'cell_border_none' => 'None',
+    'cell_border_solid' => 'Đặc',
+    'cell_border_dotted' => 'Chấm chấm',
+    'cell_border_dashed' => 'Nét đứt',
+    'cell_border_double' => 'Đôi',
+    'cell_border_groove' => 'Rãnh',
+    'cell_border_ridge' => 'Gờ',
+    'cell_border_inset' => 'Lõm',
+    'cell_border_outset' => 'Lồi',
+    'cell_border_none' => 'Không',
     'cell_border_hidden' => 'Ẩn',
 
     // Images, links, details/summary & embed
     'source' => 'Nguồn',
     'alt_desc' => 'Mô tả thay thế',
     'embed' => 'Mã nhúng',
-    'paste_embed' => 'Dán mã đính kèm của bạn vào bên dưới:',
+    'paste_embed' => 'Dán mã nhúng của bạn vào bên dưới:',
     'url' => 'Đường dẫn',
-    'text_to_display' => 'Các văn bản hiển thị',
+    'text_to_display' => 'Văn bản hiển thị',
     'title' => 'Tiêu đề',
-    'browse_links' => 'Browse links',
+    'browse_links' => 'Duyệt liên kết',
     'open_link' => 'Mở liên kết',
     'open_link_in' => 'Mở liên kết trong...',
     'open_link_current' => 'Cửa sổ hiện tại',
     'open_link_new' => 'Cửa sổ mới',
     'remove_link' => 'Loại bỏ liên kết',
-    'insert_collapsible' => 'Insert collapsible block',
-    'collapsible_unwrap' => 'Unwrap',
+    'insert_collapsible' => 'Chèn khối có thể thu gọn',
+    'collapsible_unwrap' => 'Tháo bỏ',
     'edit_label' => 'Chỉnh sửa nhãn',
-    'toggle_open_closed' => 'Toggle open/closed',
-    'collapsible_edit' => 'Edit collapsible block',
-    'toggle_label' => 'Toggle label',
+    'toggle_open_closed' => 'Chuyển đổi mở/đóng',
+    'collapsible_edit' => 'Chỉnh sửa khối có thể thu gọn',
+    'toggle_label' => 'Chuyển đổi nhãn',
 
     // About view
     'about' => 'Giới thiệu về trình soạn thảo',
     'about_title' => 'Giới thiệu về trình soạn thảo WYSIWYG',
-    'editor_license' => 'Giáy phép & Bản quyền của trình soạn thảo',
-    'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
-    'editor_lexical_license_link' => 'Full license details can be found here.',
-    'editor_tiny_license' => 'Trình soạn thảo này được xây dựng bằng các sử dụng :tinyLink theo giấy phép MIT.',
+    'editor_license' => 'Giấy phép & Bản quyền của trình soạn thảo',
+    'editor_lexical_license' => 'Trình soạn thảo này được xây dựng dựa trên :lexicalLink được phân phối theo giấy phép MIT.',
+    'editor_lexical_license_link' => 'Chi tiết giấy phép đầy đủ có thể tìm thấy tại đây.',
+    'editor_tiny_license' => 'Trình soạn thảo này được xây dựng bằng cách sử dụng :tinyLink theo giấy phép MIT.',
     'editor_tiny_license_link' => 'Chi tiết về bản quyền và giấy phép của TinyMCE có thể được tìm thấy tại đây.',
     'save_continue' => 'Lưu trang & Tiếp tục',
-    'callouts_cycle' => '(Keep pressing to toggle through types)',
-    'link_selector' => 'Liên kết đến nội dung',
+    'callouts_cycle' => '(Nhấn tiếp để chuyển đổi giữa các loại)',
+    'link_selector' => 'Bộ chọn liên kết',
     'shortcuts' => 'Phím tắt',
     'shortcut' => 'Phím tắt',
-    'shortcuts_intro' => 'The following shortcuts are available in the editor:',
+    'shortcuts_intro' => 'Các phím tắt sau có sẵn trong trình soạn thảo:',
     'windows_linux' => '(Windows/Linux)',
     'mac' => '(Mac)',
-    'description' => 'Description',
+    'description' => 'Mô tả',
 ];
diff --git a/lang/vi/entities.php b/lang/vi/entities.php
index 889af7749..f538f993c 100644
--- a/lang/vi/entities.php
+++ b/lang/vi/entities.php
@@ -23,46 +23,50 @@ return [
     'meta_updated' => 'Được cập nhật :timeLength',
     'meta_updated_name' => 'Được cập nhật :timeLength bởi :user',
     'meta_owned_name' => 'Được sở hữu bởi :user',
-    'meta_reference_count' => 'Referenced by :count item|Referenced by :count items',
+    'meta_reference_count' => 'Được tham chiếu bởi :count mục|Được tham chiếu bởi :count mục',
     'entity_select' => 'Chọn thực thể',
     'entity_select_lack_permission' => 'Bạn không có quyền để chọn mục này',
     'images' => 'Ảnh',
     'my_recent_drafts' => 'Bản nháp gần đây của tôi',
-    'my_recently_viewed' => 'Xem gần đây',
+    'my_recently_viewed' => 'Xem gần đây của tôi',
     'my_most_viewed_favourites' => 'Yêu thích được tôi xem nhiều nhất',
     'my_favourites' => 'Danh sách yêu thích của tôi',
     'no_pages_viewed' => 'Bạn chưa xem bất cứ trang nào',
     'no_pages_recently_created' => 'Không có trang nào được tạo gần đây',
     'no_pages_recently_updated' => 'Không có trang nào được cập nhật gần đây',
-    'export' => 'Kết xuất',
+    'export' => 'Xuất',
     'export_html' => 'Đang chứa tệp tin Web',
     'export_pdf' => 'Tệp PDF',
     'export_text' => 'Tệp văn bản thuần túy',
-    'export_md' => 'Tệp Markdown',
-    'export_zip' => 'Portable ZIP',
-    'default_template' => 'Default Page Template',
-    'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
-    'default_template_select' => 'Select a template page',
-    'import' => 'Import',
-    'import_validate' => 'Validate Import',
-    'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
-    'import_zip_select' => 'Select ZIP file to upload',
-    'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
-    'import_pending' => 'Pending Imports',
-    'import_pending_none' => 'No imports have been started.',
-    'import_continue' => 'Continue Import',
-    'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
-    'import_details' => 'Import Details',
-    'import_run' => 'Run Import',
-    'import_size' => ':size Import ZIP Size',
-    'import_uploaded_at' => 'Uploaded :relativeTime',
-    'import_uploaded_by' => 'Uploaded by',
-    'import_location' => 'Import Location',
-    'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
-    'import_delete_confirm' => 'Are you sure you want to delete this import?',
-    'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
-    'import_errors' => 'Import Errors',
-    'import_errors_desc' => 'The follow errors occurred during the import attempt:',
+    'export_md' => 'Tệp Markdown',
+    'export_zip' => 'ZIP di động',
+    'default_template' => 'Mẫu trang mặc định',
+    'default_template_explain' => 'Chỉ định một mẫu trang sẽ được sử dụng làm nội dung mặc định cho tất cả các trang được tạo trong mục này. Hãy chú ý rằng điều này sẽ chỉ được sử dụng nếu người tạo trang có quyền truy cập xem vào trang mẫu đã chọn.',
+    'default_template_select' => 'Chọn một trang mẫu',
+    'import' => 'Nhập',
+    'import_validate' => 'Xác thực nhập',
+    'import_desc' => 'Nhập sách, chương & trang bằng cách sử dụng tệp xuất zip di động từ cùng một phiên bản hoặc một phiên bản khác. Chọn tệp ZIP để tiếp tục. Sau khi tệp được tải lên và xác thực, bạn sẽ có thể cấu hình & xác nhận việc nhập trong chế độ xem tiếp theo.',
+    'import_zip_select' => 'Chọn tệp ZIP để tải lên',
+    'import_zip_validation_errors' => 'Đã phát hiện lỗi khi xác thực tệp ZIP được cung cấp:',
+    'import_pending' => 'Nhập đang chờ xử lý',
+    'import_pending_none' => 'Chưa có lượt nhập nào được bắt đầu.',
+    'import_continue' => 'Tiếp tục nhập',
+    'import_continue_desc' => 'Xem lại nội dung sẽ được nhập từ tệp ZIP đã tải lên. Khi sẵn sàng, hãy chạy nhập để thêm nội dung của nó vào hệ thống này. Tệp nhập ZIP đã tải lên sẽ tự động bị xóa khi nhập thành công.',
+    'import_details' => 'Chi tiết nhập',
+    'import_run' => 'Chạy nhập',
+    'import_size' => 'Kích thước tệp ZIP nhập: :size',
+    'import_uploaded_at' => 'Đã tải lên :relativeTime',
+    'import_uploaded_by' => 'Đã tải lên bởi',
+    'import_location' => 'Vị trí nhập',
+    'import_location_desc' => 'Chọn vị trí đích cho nội dung đã nhập của bạn. Bạn sẽ cần các quyền liên quan để tạo trong vị trí bạn chọn.',
+    'import_delete_confirm' => 'Bạn có chắc chắn muốn xóa lượt nhập này không?',
+    'import_delete_desc' => 'Thao tác này sẽ xóa tệp ZIP đã tải lên, và không thể hoàn tác.',
+    'import_errors' => 'Lỗi khi nhập dữ liệu',
+    'import_errors_desc' => 'Các lỗi sau đã xảy ra trong quá trình nhập:',
+    'breadcrumb_siblings_for_page' => 'Điều hướng anh chị em cho trang',
+    'breadcrumb_siblings_for_chapter' => 'Điều hướng anh chị em cho chương',
+    'breadcrumb_siblings_for_book' => 'Điều hướng anh chị em cho sách',
+    'breadcrumb_siblings_for_bookshelf' => 'Điều hướng anh chị em cho kệ sách',
 
     // Permissions and restrictions
     'permissions' => 'Quyền',
@@ -79,16 +83,16 @@ return [
     // Search
     'search_results' => 'Kết quả Tìm kiếm',
     'search_total_results_found' => 'Tìm thấy :count kết quả|:count tổng kết quả',
-    'search_clear' => 'Xoá tìm kiếm',
+    'search_clear' => 'Xóa tìm kiếm',
     'search_no_pages' => 'Không trang nào khớp với tìm kiếm này',
     'search_for_term' => 'Tìm kiếm cho :term',
     'search_more' => 'Thêm kết quả',
     'search_advanced' => 'Tìm kiếm Nâng cao',
     'search_terms' => 'Cụm từ Tìm kiếm',
-    'search_content_type' => 'Kiểu Nội dung',
+    'search_content_type' => 'Kiểu Nội dung',
     'search_exact_matches' => 'Hoàn toàn trùng khớp',
-    'search_tags' => 'Tìm kiếm Tag',
-    'search_options' => 'Tuỳ chọn',
+    'search_tags' => 'Tìm kiếm Thẻ',
+    'search_options' => 'Tùy chọn',
     'search_viewed_by_me' => 'Được xem bởi tôi',
     'search_not_viewed_by_me' => 'Không được xem bởi tôi',
     'search_permissions_set' => 'Phân quyền',
@@ -104,37 +108,37 @@ return [
     'search_update' => 'Cập nhật tìm kiếm',
 
     // Shelves
-    'shelf' => 'Giá',
-    'shelves' => 'Giá',
-    'x_shelves' => ':count Giá |:count Giá',
-    'shelves_empty' => 'Không có giá nào được tạo',
-    'shelves_create' => 'Tạo Giá mới',
-    'shelves_popular' => 'Các Giá phổ biến',
-    'shelves_new' => 'Các Giá mới',
-    'shelves_new_action' => 'Giá mới',
-    'shelves_popular_empty' => 'Các giá phổ biến sẽ xuất hiện ở đây.',
-    'shelves_new_empty' => 'Các Giá được tạo gần đây sẽ xuất hiện ở đây.',
-    'shelves_save' => 'Lưu Giá',
-    'shelves_books' => 'Sách trên Giá này',
-    'shelves_add_books' => 'Thêm sách vào Giá này',
+    'shelf' => 'Giá sách',
+    'shelves' => 'Giá sách',
+    'x_shelves' => ':count Giá sách|:count Giá sách',
+    'shelves_empty' => 'Không có giá sách nào được tạo',
+    'shelves_create' => 'Tạo Giá sách mới',
+    'shelves_popular' => 'Các Giá sách phổ biến',
+    'shelves_new' => 'Các Giá sách mới',
+    'shelves_new_action' => 'Giá sách mới',
+    'shelves_popular_empty' => 'Các giá sách phổ biến sẽ xuất hiện ở đây.',
+    'shelves_new_empty' => 'Các Giá sách được tạo gần đây sẽ xuất hiện ở đây.',
+    'shelves_save' => 'Lưu Giá sách',
+    'shelves_books' => 'Sách trên Giá sách này',
+    'shelves_add_books' => 'Thêm sách vào Giá sách này',
     'shelves_drag_books' => 'Kéo sách bên dưới để thêm vào kệ sách này',
-    'shelves_empty_contents' => 'Giá này không có sách nào',
+    'shelves_empty_contents' => 'Giá sách này không có sách nào',
     'shelves_edit_and_assign' => 'Chỉnh sửa kệ để gán sách',
     'shelves_edit_named' => 'Chỉnh sửa kệ :name',
     'shelves_edit' => 'Chỉnh sửa kệ',
     'shelves_delete' => 'Xóa kệ',
     'shelves_delete_named' => 'Xóa kệ :name',
     'shelves_delete_explain' => "Thao tác này sẽ xóa kệ có tên ':name'. Sách chứa sẽ không bị xóa.",
-    'shelves_delete_confirmation' => 'Are you sure you want to delete this shelf?',
-    'shelves_permissions' => 'Shelf Permissions',
-    'shelves_permissions_updated' => 'Shelf Permissions Updated',
+    'shelves_delete_confirmation' => 'Bạn có chắc chắn muốn xóa kệ sách này không?',
+    'shelves_permissions' => 'Quyền cho kệ sách',
+    'shelves_permissions_updated' => 'Quyền cho kệ sách đã được cập nhật',
     'shelves_permissions_active' => 'Quyền của kệ đang hoạt động',
-    'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
-    'shelves_permissions_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.',
+    'shelves_permissions_cascade_warning' => 'Quyền trên kệ sách không tự động xếp theo các sách được chứa. Điều này là do một cuốn sách có thể tồn tại trên nhiều kệ. Tuy nhiên, quyền có thể được sao chép xuống sách con bằng cách sử dụng tùy chọn bên dưới.',
+    'shelves_permissions_create' => 'Quyền tạo giá sách chỉ được sử dụng để sao chép quyền vào sách con bằng cách sử dụng tác vụ bên dưới. Chúng không kiểm soát khả năng tạo ra sách.',
     'shelves_copy_permissions_to_books' => 'Sao chép các quyền cho sách',
     'shelves_copy_permissions' => 'Sao chép các quyền',
-    'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.',
-    'shelves_copy_permission_success' => 'Shelf permissions copied to :count books',
+    'shelves_copy_permissions_explain' => 'Thao tác này sẽ áp dụng cài đặt quyền hiện tại của giá sách này cho tất cả sách có trong đó. Trước khi kích hoạt, hãy đảm bảo mọi thay đổi đối với quyền của giá sách này đã được lưu.',
+    'shelves_copy_permission_success' => 'Đã sao chép quyền của kệ vào :count sách',
 
     // Books
     'book' => 'Sách',
@@ -159,16 +163,16 @@ return [
     'books_permissions' => 'Các quyền của cuốn sách',
     'books_permissions_updated' => 'Các quyền của cuốn sách đã được cập nhật',
     'books_empty_contents' => 'Không có trang hay chương nào được tạo cho cuốn sách này.',
-    'books_empty_create_page' => 'Tao một trang mới',
+    'books_empty_create_page' => 'Tạo một trang mới',
     'books_empty_sort_current_book' => 'Sắp xếp cuốn sách này',
     'books_empty_add_chapter' => 'Thêm một chương mới',
     'books_permissions_active' => 'Đang bật các quyền hạn từ Sách',
     'books_search_this' => 'Tìm cuốn sách này',
     'books_navigation' => 'Điều hướng cuốn sách',
     'books_sort' => 'Sắp xếp nội dung cuốn sách',
-    'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.',
-    'books_sort_auto_sort' => 'Auto Sort Option',
-    'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName',
+    'books_sort_desc' => 'Di chuyển các chương và trang trong một cuốn sách để sắp xếp lại nội dung của nó. Các sách khác có thể được thêm vào để dễ dàng di chuyển các chương và trang giữa các sách. Tùy chọn, một quy tắc sắp xếp tự động có thể được đặt để tự động sắp xếp nội dung cuốn sách này khi có thay đổi.',
+    'books_sort_auto_sort' => 'Tùy chọn sắp xếp tự động',
+    'books_sort_auto_sort_active' => 'Sắp xếp tự động đang hoạt động: :sortName',
     'books_sort_named' => 'Sắp xếp sách :bookName',
     'books_sort_name' => 'Sắp xếp theo tên',
     'books_sort_created' => 'Sắp xếp theo ngày tạo',
@@ -177,19 +181,19 @@ return [
     'books_sort_chapters_last' => 'Các Chương cuối',
     'books_sort_show_other' => 'Hiển thị các Sách khác',
     'books_sort_save' => 'Lưu thứ tự mới',
-    'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
-    'books_sort_move_up' => 'Move Up',
-    'books_sort_move_down' => 'Move Down',
-    'books_sort_move_prev_book' => 'Move to Previous Book',
-    'books_sort_move_next_book' => 'Move to Next Book',
-    'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
-    'books_sort_move_next_chapter' => 'Move Into Next Chapter',
-    'books_sort_move_book_start' => 'Move to Start of Book',
-    'books_sort_move_book_end' => 'Move to End of Book',
-    'books_sort_move_before_chapter' => 'Move to Before Chapter',
-    'books_sort_move_after_chapter' => 'Move to After Chapter',
-    'books_copy' => 'Copy Book',
-    'books_copy_success' => 'Book successfully copied',
+    'books_sort_show_other_desc' => 'Thêm các sách khác vào đây để đưa chúng vào thao tác sắp xếp và cho phép sắp xếp lại nhiều sách dễ dàng.',
+    'books_sort_move_up' => 'Đưa lên trên',
+    'books_sort_move_down' => 'Đưa xuống dưới',
+    'books_sort_move_prev_book' => 'Chuyển tới sách phía trước',
+    'books_sort_move_next_book' => 'Chuyển tới sách phía sau',
+    'books_sort_move_prev_chapter' => 'Chuyển sang chương trước',
+    'books_sort_move_next_chapter' => 'Chuyển sang chương tiếp theo',
+    'books_sort_move_book_start' => 'Di chuyển đến đầu sách',
+    'books_sort_move_book_end' => 'Di chuyển đến cuối sách',
+    'books_sort_move_before_chapter' => 'Chuyển về trước chương',
+    'books_sort_move_after_chapter' => 'Chuyển tới chương sau',
+    'books_copy' => 'Sao chép sách',
+    'books_copy_success' => 'Đã sao chép thành công',
 
     // Chapters
     'chapter' => 'Chương',
@@ -207,14 +211,14 @@ return [
     'chapters_save' => 'Lưu Chương',
     'chapters_move' => 'Di chuyển Chương',
     'chapters_move_named' => 'Di chuyển Chương :chapterName',
-    'chapters_copy' => 'Copy Chapter',
-    'chapters_copy_success' => 'Chapter successfully copied',
+    'chapters_copy' => 'Sao chép chương',
+    'chapters_copy_success' => 'Chương đã được sao chép thành công',
     'chapters_permissions' => 'Quyền hạn Chương',
     'chapters_empty' => 'Không có trang nào hiện có trong chương này.',
     'chapters_permissions_active' => 'Đang bật các quyền hạn từ Chương',
     'chapters_permissions_success' => 'Quyền hạn Chương được cập nhật',
     'chapters_search_this' => 'Tìm kiếm trong Chương này',
-    'chapter_sort_book' => 'Sort Book',
+    'chapter_sort_book' => 'Sắp xếp sách',
 
     // Pages
     'page' => 'Trang',
@@ -230,7 +234,7 @@ return [
     'pages_delete_draft' => 'Xóa Trang Nháp',
     'pages_delete_success' => 'Đã xóa Trang',
     'pages_delete_draft_success' => 'Đã xóa trang Nháp',
-    'pages_delete_warning_template' => '.',
+    'pages_delete_warning_template' => 'Trang này đang được sử dụng làm mẫu trang mặc định của sách hoặc chương. Hãy chú ý: Những cuốn sách hoặc chương này sẽ không còn được chỉ định mẫu trang mặc định sau khi trang này bị xóa.',
     'pages_delete_confirm' => 'Bạn có chắc chắn muốn xóa trang này?',
     'pages_delete_draft_confirm' => 'Bạn có chắc chắn muốn xóa trang nháp này?',
     'pages_editing_named' => 'Đang chỉnh sửa Trang :pageName',
@@ -241,23 +245,23 @@ return [
     'pages_editing_page' => 'Đang chỉnh sửa Trang',
     'pages_edit_draft_save_at' => 'Bản nháp đã lưu lúc ',
     'pages_edit_delete_draft' => 'Xóa Bản nháp',
-    'pages_edit_delete_draft_confirm' => 'Are you sure you want to delete your draft page changes? All of your changes, since the last full save, will be lost and the editor will be updated with the latest page non-draft save state.',
+    'pages_edit_delete_draft_confirm' => 'Bạn có chắc chắn muốn xóa các thay đổi trên trang nháp của mình không? Tất cả các thay đổi của bạn, kể từ lần lưu đầy đủ gần đây nhất, sẽ bị mất và trình chỉnh sửa sẽ được cập nhật với trạng thái lưu trang không phải bản nháp mới nhất.',
     'pages_edit_discard_draft' => 'Hủy bỏ Bản nháp',
-    'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor',
-    'pages_edit_switch_to_markdown_clean' => '(Clean Content)',
-    'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
+    'pages_edit_switch_to_markdown' => 'Chuyển sang trình soạn thảo Markdown',
+    'pages_edit_switch_to_markdown_clean' => '(Nội dung sạch)',
+    'pages_edit_switch_to_markdown_stable' => '(Nội dung ổn định)',
     'pages_edit_switch_to_wysiwyg' => 'Chuyển sang trình soạn thảo WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
+    'pages_edit_switch_to_new_wysiwyg' => 'Chuyển sang WYSIWYG mới',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '(Đang thử nghiệm Beta)',
     'pages_edit_set_changelog' => 'Đặt Changelog',
     'pages_edit_enter_changelog_desc' => 'Viết mô tả ngắn gọn cho các thay đổi mà bạn tạo',
     'pages_edit_enter_changelog' => 'Viết Changelog',
-    'pages_editor_switch_title' => 'Switch Editor',
-    'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?',
-    'pages_editor_switch_consider_following' => 'Consider the following when changing editors:',
+    'pages_editor_switch_title' => 'Đổi trình soạn thảo',
+    'pages_editor_switch_are_you_sure' => 'Bạn có chắc chắn muốn thay đổi trình soạn thảo cho trang này không?',
+    'pages_editor_switch_consider_following' => 'Hãy cân nhắc những điều sau đây khi thay đổi trình soạn thảo:',
     'pages_editor_switch_consideration_a' => 'Sau khi lưu, tùy chọn trình soạn thảo mới sẽ được sử dụng bởi bất kỳ trình chỉnh sửa nào trong tương lai, kể cả những người không thể tự thay đổi loại trình chỉnh sửa.',
-    'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.',
-    'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.',
+    'pages_editor_switch_consideration_b' => 'Điều này có khả năng dẫn đến mất chi tiết và cú pháp trong một số trường hợp nhất định.',
+    'pages_editor_switch_consideration_c' => 'Các thay đổi thẻ hoặc nhật ký thay đổi, được thực hiện kể từ lần lưu cuối cùng, sẽ không tồn tại sau thay đổi này.',
     'pages_save' => 'Lưu Trang',
     'pages_title' => 'Tiêu đề Trang',
     'pages_name' => 'Tên Trang',
@@ -266,10 +270,11 @@ return [
     'pages_md_insert_image' => 'Chèn hình ảnh',
     'pages_md_insert_link' => 'Chèn liên kết thực thể',
     'pages_md_insert_drawing' => 'Chèn bản vẽ',
-    'pages_md_show_preview' => 'Show preview',
-    'pages_md_sync_scroll' => 'Sync preview scroll',
-    'pages_drawing_unsaved' => 'Unsaved Drawing Found',
-    'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
+    'pages_md_show_preview' => 'Hiển thị bản xem trước',
+    'pages_md_sync_scroll' => 'Đồng bộ hóa cuộn xem trước',
+    'pages_md_plain_editor' => 'Trình soạn thảo văn bản thuần túy',
+    'pages_drawing_unsaved' => 'Tìm thấy bản vẽ chưa lưu',
+    'pages_drawing_unsaved_confirm' => 'Dữ liệu bản vẽ chưa lưu được tìm thấy từ lần lưu bản vẽ không thành công trước đó. Bạn có muốn khôi phục và tiếp tục chỉnh sửa bản vẽ chưa lưu này không?',
     'pages_not_in_chapter' => 'Trang không nằm trong một chương',
     'pages_move' => 'Di chuyển Trang',
     'pages_copy' => 'Sao chép Trang',
@@ -279,17 +284,17 @@ return [
     'pages_permissions_success' => 'Quyền hạn Trang được cập nhật',
     'pages_revision' => 'Phiên bản',
     'pages_revisions' => 'Phiên bản Trang',
-    'pages_revisions_desc' => 'Listed below are all the past revisions of this page. You can look back upon, compare, and restore old page versions if permissions allow. The full history of the page may not be fully reflected here since, depending on system configuration, old revisions could be auto-deleted.',
+    'pages_revisions_desc' => 'Dưới đây là tất cả các bản sửa đổi trước đây của trang này. Bạn có thể xem lại, so sánh và khôi phục các phiên bản trang cũ nếu được phép. Lịch sử đầy đủ của trang có thể không được phản ánh đầy đủ ở đây vì, tùy thuộc vào cấu hình hệ thống, các bản sửa đổi cũ có thể tự động bị xóa.',
     'pages_revisions_named' => 'Phiên bản Trang cho :pageName',
     'pages_revision_named' => 'Phiên bản Trang cho :pageName',
     'pages_revision_restored_from' => 'Khôi phục từ #:id; :summary',
     'pages_revisions_created_by' => 'Tạo bởi',
     'pages_revisions_date' => 'Ngày của Phiên bản',
     'pages_revisions_number' => '#',
-    'pages_revisions_sort_number' => 'Revision Number',
+    'pages_revisions_sort_number' => 'Số phiên bản',
     'pages_revisions_numbered' => 'Phiên bản #:id',
     'pages_revisions_numbered_changes' => 'Các thay đổi của phiên bản #:id',
-    'pages_revisions_editor' => 'Editor Type',
+    'pages_revisions_editor' => 'Loại trình chỉnh sửa',
     'pages_revisions_changelog' => 'Nhật ký thay đổi',
     'pages_revisions_changes' => 'Các thay đổi',
     'pages_revisions_current' => 'Phiên bản hiện tại',
@@ -297,65 +302,65 @@ return [
     'pages_revisions_restore' => 'Khôi phục',
     'pages_revisions_none' => 'Trang này không có phiên bản nào',
     'pages_copy_link' => 'Sao chép Liên kết',
-    'pages_edit_content_link' => 'Jump to section in editor',
-    'pages_pointer_enter_mode' => 'Enter section select mode',
+    'pages_edit_content_link' => 'Chuyển đến phần trong trình chỉnh sửa',
+    'pages_pointer_enter_mode' => 'Vào chế độ chọn phần',
     'pages_pointer_label' => 'Tùy chọn phần trang',
-    'pages_pointer_permalink' => 'Phần trang Liên kết cố định',
+    'pages_pointer_permalink' => 'Liên kết cố định phần trang',
     'pages_pointer_include_tag' => 'Phần trang bao gồm thẻ',
     'pages_pointer_toggle_link' => 'Chế độ Liên kết cố định, Nhấn để hiển thị thẻ bao gồm',
     'pages_pointer_toggle_include' => 'Bao gồm chế độ thẻ, Nhấn để hiển thị liên kết cố định',
     'pages_permissions_active' => 'Đang bật các quyền hạn từ Trang',
     'pages_initial_revision' => 'Đăng bài mở đầu',
-    'pages_references_update_revision' => 'System auto-update of internal links',
+    'pages_references_update_revision' => 'Hệ thống tự động cập nhật liên kết nội bộ',
     'pages_initial_name' => 'Trang mới',
     'pages_editing_draft_notification' => 'Bạn hiện đang chỉnh sửa một bản nháp được lưu cách đây :timeDiff.',
     'pages_draft_edited_notification' => 'Trang này đã được cập nhật từ lúc đó. Bạn nên loại bỏ bản nháp này.',
-    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
+    'pages_draft_page_changed_since_creation' => 'Trang này đã được cập nhật kể từ khi bản nháp này được tạo. Bạn nên bỏ bản nháp này hoặc cẩn thận không ghi đè bất kỳ thay đổi nào của trang.',
     'pages_draft_edit_active' => [
         'start_a' => ':count người dùng đang bắt đầu chỉnh sửa trang này',
         'start_b' => ':userName đang bắt đầu chỉnh sửa trang này',
-        'time_a' => 'kể từ khi thang được cập nhật lần cuối',
+        'time_a' => 'kể từ khi trang được cập nhật lần cuối',
         'time_b' => 'trong :minCount phút cuối',
         'message' => ':start :time. Hãy cẩn thận đừng ghi đè vào các bản cập nhật của nhau!',
     ],
-    'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content',
-    'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content',
+    'pages_draft_discarded' => 'Bản nháp đã bị loại bỏ! Trình chỉnh sửa đã được cập nhật với nội dung trang hiện tại',
+    'pages_draft_deleted' => 'Bản nháp đã bị xóa! Trình chỉnh sửa đã được cập nhật với nội dung trang hiện tại',
     'pages_specific' => 'Trang cụ thể',
     'pages_is_template' => 'Biểu mẫu trang',
 
     // Editor Sidebar
-    'toggle_sidebar' => 'Toggle Sidebar',
+    'toggle_sidebar' => 'Chuyển đổi thanh bên',
     'page_tags' => 'Các Thẻ Trang',
     'chapter_tags' => 'Các Thẻ Chương',
     'book_tags' => 'Các Thẻ Sách',
     'shelf_tags' => 'Các Thẻ Kệ',
     'tag' => 'Nhãn',
     'tags' =>  'Các Thẻ',
-    'tags_index_desc' => 'Tags can be applied to content within the system to apply a flexible form of categorization. Tags can have both a key and value, with the value being optional. Once applied, content can then be queried using the tag name and value.',
+    'tags_index_desc' => 'Thẻ có thể được áp dụng cho nội dung trong hệ thống để áp dụng một hình thức phân loại linh hoạt. Thẻ có thể có cả khóa và giá trị, với giá trị là tùy chọn. Sau khi được áp dụng, nội dung sau đó có thể được truy vấn bằng tên thẻ và giá trị.',
     'tag_name' =>  'Tên Nhãn',
     'tag_value' => 'Giá trị Thẻ (Tùy chọn)',
     'tags_explain' => "Thêm vài thẻ để phân loại nội dung của bạn tốt hơn. \n Bạn có thể đặt giá trị cho thẻ để quản lí kĩ càng hơn.",
     'tags_add' => 'Thêm thẻ khác',
     'tags_remove' => 'Xóa thẻ này',
-    'tags_usages' => 'Total tag usages',
-    'tags_assigned_pages' => 'Assigned to Pages',
-    'tags_assigned_chapters' => 'Assigned to Chapters',
-    'tags_assigned_books' => 'Assigned to Books',
-    'tags_assigned_shelves' => 'Assigned to Shelves',
-    'tags_x_unique_values' => ':count unique values',
-    'tags_all_values' => 'All values',
-    'tags_view_tags' => 'View Tags',
-    'tags_view_existing_tags' => 'View existing tags',
-    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
+    'tags_usages' => 'Tổng số lượt sử dụng thẻ',
+    'tags_assigned_pages' => 'Đã gán cho Trang',
+    'tags_assigned_chapters' => 'Đã gán cho Chương',
+    'tags_assigned_books' => 'Đã gán cho Sách',
+    'tags_assigned_shelves' => 'Đã gán cho Kệ',
+    'tags_x_unique_values' => ':count giá trị duy nhất',
+    'tags_all_values' => 'Tất cả giá trị',
+    'tags_view_tags' => 'Xem Thẻ',
+    'tags_view_existing_tags' => 'Xem các thẻ hiện có',
+    'tags_list_empty_hint' => 'Thẻ có thể được gán thông qua thanh bên của trình chỉnh sửa trang hoặc trong khi chỉnh sửa chi tiết của sách, chương hoặc kệ.',
     'attachments' => 'Các Đính kèm',
     'attachments_explain' => 'Cập nhật một số tập tin và đính một số liên kết để hiển thị trên trang của bạn. Chúng được hiện trong sidebar của trang.',
     'attachments_explain_instant_save' => 'Các thay đổi ở đây sẽ được lưu ngay lập tức.',
     'attachments_upload' => 'Tải lên Tập tin',
     'attachments_link' => 'Đính kèm Liên kết',
-    'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.',
+    'attachments_upload_drop' => 'Ngoài ra, bạn có thể kéo và thả tệp vào đây để tải lên làm tệp đính kèm.',
     'attachments_set_link' => 'Đặt Liên kết',
     'attachments_delete' => 'Bạn có chắc chắn muốn xóa tập tin đính kèm này?',
-    'attachments_dropzone' => 'Drop files here to upload',
+    'attachments_dropzone' => 'Thả tệp vào đây để tải lên',
     'attachments_no_files' => 'Không có tập tin nào được tải lên',
     'attachments_explain_link' => 'Bạn có thể đính kèm một liên kết nếu bạn lựa chọn không tải lên tập tin. Liên kết này có thể trỏ đến một trang khác hoặc một tập tin ở trên mạng (đám mây).',
     'attachments_link_name' => 'Tên Liên kết',
@@ -375,7 +380,7 @@ return [
     'attachments_link_attached' => 'Liên kết được đính kèm đến trang thành công',
     'templates' => 'Các Mẫu',
     'templates_set_as_template' => 'Trang là một mẫu',
-    'templates_explain_set_as_template' => 'Bạn có thể đặt trang này làm mẫu, nội dung của nó sẽ được sử dụng lại khi tạo các trang mới. Người dùng khác có thể sử dụng mẫu này nếu học có quyền hạn xem trang này.',
+    'templates_explain_set_as_template' => 'Bạn có thể đặt trang này làm mẫu, nội dung của nó sẽ được sử dụng lại khi tạo các trang mới. Người dùng khác có thể sử dụng mẫu này nếu họ có quyền hạn xem trang này.',
     'templates_replace_content' => 'Thay thế nội dung trang',
     'templates_append_content' => 'Viết vào nội dung trang',
     'templates_prepend_content' => 'Thêm vào đầu nội dung trang',
@@ -392,19 +397,28 @@ return [
     'comment' => 'Bình luận',
     'comments' => 'Các bình luận',
     'comment_add' => 'Thêm bình luận',
+    'comment_none' => 'Không có bình luận nào để hiển thị',
     'comment_placeholder' => 'Đăng bình luận tại đây',
-    'comment_count' => '{0} Không có bình luận|{1} 1 Bình luận|[2,*] :count Bình luận',
+    'comment_thread_count' => ':count Chuỗi bình luận|:count Chuỗi bình luận',
+    'comment_archived_count' => ':count Đã lưu trữ',
+    'comment_archived_threads' => 'Chuỗi đã lưu trữ',
     'comment_save' => 'Lưu bình luận',
     'comment_new' => 'Bình luận mới',
     'comment_created' => 'đã bình luận :createDiff',
     'comment_updated' => 'Đã cập nhật :updateDiff bởi :username',
-    'comment_updated_indicator' => 'Updated',
+    'comment_updated_indicator' => 'Đã cập nhật',
     'comment_deleted_success' => 'Bình luận đã bị xóa',
     'comment_created_success' => 'Đã thêm bình luận',
     'comment_updated_success' => 'Bình luận đã được cập nhật',
+    'comment_archive_success' => 'Đã lưu trữ bình luận',
+    'comment_unarchive_success' => 'Đã bỏ lưu trữ bình luận',
+    'comment_view' => 'Xem bình luận',
+    'comment_jump_to_thread' => 'Chuyển đến chuỗi',
     'comment_delete_confirm' => 'Bạn có chắc bạn muốn xóa bình luận này?',
     'comment_in_reply_to' => 'Trả lời cho :commentId',
-    'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.',
+    'comment_reference' => 'Tham chiếu',
+    'comment_reference_outdated' => '(Đã lỗi thời)',
+    'comment_editor_explain' => 'Đây là những bình luận đã được để lại trên trang này. Bình luận có thể được thêm & quản lý khi xem trang đã lưu.',
 
     // Revision
     'revision_delete_confirm' => 'Bạn có chắc bạn muốn xóa phiên bản này?',
@@ -412,51 +426,51 @@ return [
     'revision_cannot_delete_latest' => 'Không thể xóa phiên bản mới nhất.',
 
     // Copy view
-    'copy_consider' => 'Please consider the below when copying content.',
-    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
-    'copy_consider_owner' => 'You will become the owner of all copied content.',
-    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
-    'copy_consider_attachments' => 'Page attachments will not be copied.',
-    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
+    'copy_consider' => 'Vui lòng xem xét những điều sau đây khi sao chép nội dung.',
+    'copy_consider_permissions' => 'Cài đặt quyền tùy chỉnh sẽ không được sao chép.',
+    'copy_consider_owner' => 'Bạn sẽ trở thành chủ sở hữu của tất cả nội dung được sao chép.',
+    'copy_consider_images' => 'Các tệp hình ảnh trang sẽ không được nhân đôi & các hình ảnh gốc sẽ giữ lại mối quan hệ của chúng với trang mà chúng được tải lên ban đầu.',
+    'copy_consider_attachments' => 'Các tệp đính kèm trang sẽ không được sao chép.',
+    'copy_consider_access' => 'Việc thay đổi vị trí, chủ sở hữu hoặc quyền có thể dẫn đến nội dung này có thể truy cập được đối với những người trước đây không có quyền truy cập.',
 
     // Conversions
-    'convert_to_shelf' => 'Convert to Shelf',
-    'convert_to_shelf_contents_desc' => 'You can convert this book to a new shelf with the same contents. Chapters contained within this book will be converted to new books. If this book contains any pages, that are not in a chapter, this book will be renamed and contain such pages, and this book will become part of the new shelf.',
-    'convert_to_shelf_permissions_desc' => 'Any permissions set on this book will be copied to the new shelf and to all new child books that don\'t have their own permissions enforced. Note that permissions on shelves do not auto-cascade to content within, as they do for books.',
-    'convert_book' => 'Convert Book',
-    'convert_book_confirm' => 'Are you sure you want to convert this book?',
-    'convert_undo_warning' => 'This cannot be as easily undone.',
-    'convert_to_book' => 'Convert to Book',
-    'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.',
-    'convert_chapter' => 'Convert Chapter',
-    'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?',
+    'convert_to_shelf' => 'Chuyển đổi thành Kệ sách',
+    'convert_to_shelf_contents_desc' => 'Bạn có thể chuyển đổi cuốn sách này thành một kệ sách mới với nội dung tương tự. Các chương có trong cuốn sách này sẽ được chuyển đổi thành các cuốn sách mới. Nếu cuốn sách này chứa bất kỳ trang nào không nằm trong một chương, cuốn sách này sẽ được đổi tên và chứa các trang đó, và cuốn sách này sẽ trở thành một phần của kệ sách mới.',
+    'convert_to_shelf_permissions_desc' => 'Bất kỳ quyền nào được đặt trên cuốn sách này sẽ được sao chép sang kệ sách mới và tất cả các cuốn sách con mới không có quyền riêng của chúng. Lưu ý rằng quyền trên kệ sách không tự động xếp tầng xuống nội dung bên trong, như đối với sách.',
+    'convert_book' => 'Chuyển đổi sách',
+    'convert_book_confirm' => 'Bạn có chắc chắn muốn chuyển đổi cuốn sách này?',
+    'convert_undo_warning' => 'Việc này không thể dễ dàng hoàn tác.',
+    'convert_to_book' => 'Chuyển đổi thành Sách',
+    'convert_to_book_desc' => 'Bạn có thể chuyển đổi chương này thành một cuốn sách mới với nội dung tương tự. Bất kỳ quyền nào được đặt trên chương này sẽ được sao chép sang cuốn sách mới nhưng bất kỳ quyền thừa kế nào từ cuốn sách mẹ sẽ không được sao chép, điều này có thể dẫn đến thay đổi kiểm soát truy cập.',
+    'convert_chapter' => 'Chuyển đổi Chương',
+    'convert_chapter_confirm' => 'Bạn có chắc chắn muốn chuyển đổi chương này?',
 
     // References
-    'references' => 'References',
-    'references_none' => 'There are no tracked references to this item.',
-    'references_to_desc' => 'Listed below is all the known content in the system that links to this item.',
+    'references' => 'Tham chiếu',
+    'references_none' => 'Không có tham chiếu nào được theo dõi đến mục này.',
+    'references_to_desc' => 'Dưới đây là tất cả nội dung đã biết trong hệ thống liên kết đến mục này.',
 
     // Watch Options
-    'watch' => 'Watch',
-    'watch_title_default' => 'Default Preferences',
-    'watch_desc_default' => 'Revert watching to just your default notification preferences.',
-    'watch_title_ignore' => 'Ignore',
-    'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.',
-    'watch_title_new' => 'New Pages',
-    'watch_desc_new' => 'Notify when any new page is created within this item.',
-    'watch_title_updates' => 'All Page Updates',
-    'watch_desc_updates' => 'Notify upon all new pages and page changes.',
-    'watch_desc_updates_page' => 'Notify upon all page changes.',
-    'watch_title_comments' => 'All Page Updates & Comments',
-    'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.',
-    'watch_desc_comments_page' => 'Notify upon page changes and new comments.',
-    'watch_change_default' => 'Change default notification preferences',
-    'watch_detail_ignore' => 'Ignoring notifications',
-    'watch_detail_new' => 'Watching for new pages',
-    'watch_detail_updates' => 'Watching new pages and updates',
-    'watch_detail_comments' => 'Watching new pages, updates & comments',
-    'watch_detail_parent_book' => 'Watching via parent book',
-    'watch_detail_parent_book_ignore' => 'Ignoring via parent book',
-    'watch_detail_parent_chapter' => 'Watching via parent chapter',
-    'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter',
+    'watch' => 'Theo dõi',
+    'watch_title_default' => 'Tùy chọn mặc định',
+    'watch_desc_default' => 'Khôi phục việc theo dõi chỉ về các tùy chọn thông báo mặc định của bạn.',
+    'watch_title_ignore' => 'Bỏ qua',
+    'watch_desc_ignore' => 'Bỏ qua tất cả các thông báo, bao gồm cả những thông báo từ tùy chọn cấp người dùng.',
+    'watch_title_new' => 'Trang mới',
+    'watch_desc_new' => 'Thông báo khi bất kỳ trang mới nào được tạo trong mục này.',
+    'watch_title_updates' => 'Tất cả cập nhật trang',
+    'watch_desc_updates' => 'Thông báo khi có tất cả các trang mới và thay đổi trang.',
+    'watch_desc_updates_page' => 'Thông báo khi có tất cả các thay đổi trang.',
+    'watch_title_comments' => 'Tất cả cập nhật trang & Bình luận',
+    'watch_desc_comments' => 'Thông báo khi có tất cả các trang mới, thay đổi trang và bình luận mới.',
+    'watch_desc_comments_page' => 'Thông báo khi có thay đổi trang và bình luận mới.',
+    'watch_change_default' => 'Thay đổi tùy chọn thông báo mặc định',
+    'watch_detail_ignore' => 'Đang bỏ qua thông báo',
+    'watch_detail_new' => 'Đang theo dõi các trang mới',
+    'watch_detail_updates' => 'Đang theo dõi các trang mới và cập nhật',
+    'watch_detail_comments' => 'Đang theo dõi các trang mới, cập nhật & bình luận',
+    'watch_detail_parent_book' => 'Đang theo dõi thông qua sách cha',
+    'watch_detail_parent_book_ignore' => 'Đang bỏ qua thông qua sách cha',
+    'watch_detail_parent_chapter' => 'Đang theo dõi thông qua chương cha',
+    'watch_detail_parent_chapter_ignore' => 'Đang bỏ qua thông qua chương cha',
 ];
diff --git a/lang/vi/errors.php b/lang/vi/errors.php
index 66ad23b72..bae87dc38 100644
--- a/lang/vi/errors.php
+++ b/lang/vi/errors.php
@@ -10,7 +10,7 @@ return [
 
     // Auth
     'error_user_exists_different_creds' => 'Đã có người sử dụng email :email nhưng với thông tin định danh khác.',
-    'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details',
+    'auth_pre_register_theme_prevention' => 'Tài khoản người dùng không thể đăng ký với các chi tiết được cung cấp',
     'email_already_confirmed' => 'Email đã được xác nhận trước đó, Đang đăng nhập.',
     'email_confirmation_invalid' => 'Token xác nhận này không hợp lệ hoặc đã được sử dụng trước đó, Xin hãy thử đăng ký lại.',
     'email_confirmation_expired' => 'Token xác nhận đã hết hạn, Một email xác nhận mới đã được gửi.',
@@ -37,37 +37,37 @@ return [
     'social_driver_not_found' => 'Không tìm thấy driver cho MXH',
     'social_driver_not_configured' => 'Cài đặt MXH :socialAccount của bạn đang không được cấu hình hợp lệ.',
     'invite_token_expired' => 'Liên kết mời này đã hết hạn. Bạn có thể thử đặt lại mật khẩu của tài khoản.',
-    'login_user_not_found' => 'A user for this action could not be found.',
+    'login_user_not_found' => 'Không tìm thấy người dùng cho hành động này.',
 
     // System
     'path_not_writable' => 'Đường dẫn tệp tin :filePath không thể tải đến được. Đảm bảo rằng đường dẫn này có thể ghi được ở trên máy chủ.',
     'cannot_get_image_from_url' => 'Không thể lấy ảnh từ :url',
     'cannot_create_thumbs' => 'Máy chủ không thể tạo ảnh nhỏ. Vui lòng kiểm tra bạn đã cài đặt tiện ích mở rộng GD PHP.',
     'server_upload_limit' => 'Máy chủ không cho phép tải lên kích thước này. Vui lòng thử lại với tệp tin nhỏ hơn.',
-    'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.',
+    'server_post_limit' => 'Máy chủ không thể nhận lượng dữ liệu được cung cấp. Hãy thử lại với ít dữ liệu hoặc tệp nhỏ hơn.',
     'uploaded'  => 'Máy chủ không cho phép tải lên kích thước này. Vui lòng thử lại với tệp tin nhỏ hơn.',
 
     // Drawing & Images
     'image_upload_error' => 'Đã xảy ra lỗi khi đang tải lên ảnh',
     'image_upload_type_error' => 'Ảnh đang được tải lên không hợp lệ',
-    'image_upload_replace_type' => 'Image file replacements must be of the same type',
-    'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.',
-    'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.',
-    'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.',
-    'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
+    'image_upload_replace_type' => 'Các tệp hình ảnh thay thế phải cùng loại',
+    'image_upload_memory_limit' => 'Không xử lý được hình ảnh tải lên và/hoặc tạo hình thu nhỏ do giới hạn tài nguyên hệ thống.',
+    'image_thumbnail_memory_limit' => 'Không tạo được các biến thể kích thước hình ảnh do giới hạn tài nguyên hệ thống.',
+    'image_gallery_thumbnail_memory_limit' => 'Không tạo được hình thu nhỏ thư viện do giới hạn tài nguyên hệ thống.',
+    'drawing_data_not_found' => 'Không thể tải dữ liệu bản vẽ. Tệp bản vẽ có thể không còn tồn tại hoặc bạn không có quyền truy cập vào nó.',
 
     // Attachments
     'attachment_not_found' => 'Không tìm thấy đính kèm',
-    'attachment_upload_error' => 'An error occurred uploading the attachment file',
+    'attachment_upload_error' => 'Đã xảy ra lỗi khi tải tệp đính kèm',
 
     // Pages
     'page_draft_autosave_fail' => 'Lưu bản nháp thất bại. Đảm bảo rằng bạn có kết nối đến internet trước khi lưu trang này',
-    'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content',
+    'page_draft_delete_fail' => 'Không thể xóa bản nháp trang và lấy nội dung đã lưu của trang hiện tại',
     'page_custom_home_deletion' => 'Không thể xóa trang khi nó đang được đặt là trang chủ',
 
     // Entities
     'entity_not_found' => 'Không tìm thấy thực thể',
-    'bookshelf_not_found' => 'Shelf not found',
+    'bookshelf_not_found' => 'Không tìm thấy giá sách',
     'book_not_found' => 'Không tìm thấy sách',
     'page_not_found' => 'Không tìm thấy trang',
     'chapter_not_found' => 'Không tìm thấy chương',
@@ -78,7 +78,7 @@ return [
     // Users
     'users_cannot_delete_only_admin' => 'Bạn không thể xóa quản trị viên duy nhất',
     'users_cannot_delete_guest' => 'Bạn không thể xóa người dùng khách',
-    'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
+    'users_could_not_send_invite' => 'Không thể tạo người dùng vì email mời không gửi được',
 
     // Roles
     'role_cannot_be_edited' => 'Không thể chỉnh sửa quyền này',
@@ -106,16 +106,16 @@ return [
     'back_soon' => 'Nó sẽ sớm hoạt động trở lại.',
 
     // Import
-    'import_zip_cant_read' => 'Could not read ZIP file.',
-    'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
-    'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
-    'import_validation_failed' => 'Import ZIP failed to validate with errors:',
-    'import_zip_failed_notification' => 'Failed to import ZIP file.',
-    'import_perms_books' => 'You are lacking the required permissions to create books.',
-    'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
-    'import_perms_pages' => 'You are lacking the required permissions to create pages.',
-    'import_perms_images' => 'You are lacking the required permissions to create images.',
-    'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
+    'import_zip_cant_read' => 'Không thể đọc tệp ZIP.',
+    'import_zip_cant_decode_data' => 'Không thể tìm và giải mã nội dung ZIP data.json.',
+    'import_zip_no_data' => 'Dữ liệu tệp ZIP không có nội dung sách, chương hoặc trang mong đợi.',
+    'import_validation_failed' => 'Nhập tệp ZIP không hợp lệ với các lỗi:',
+    'import_zip_failed_notification' => 'Không thể nhập tệp ZIP.',
+    'import_perms_books' => 'Bạn không có quyền cần thiết để tạo sách.',
+    'import_perms_chapters' => 'Bạn không có quyền cần thiết để tạo chương.',
+    'import_perms_pages' => 'Bạn không có quyền cần thiết để tạo trang.',
+    'import_perms_images' => 'Bạn không có quyền cần thiết để tạo hình ảnh.',
+    'import_perms_attachments' => 'Bạn không có quyền cần thiết để tạo tệp đính kèm.',
 
     // API errors
     'api_no_authorization_found' => 'Không tìm thấy token ủy quyền trong yêu cầu',
@@ -129,5 +129,5 @@ return [
     'maintenance_test_email_failure' => 'Lỗi khi gửi email thử:',
 
     // HTTP errors
-    'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts',
+    'http_ssr_url_no_match' => 'URL không khớp với các máy chủ SSR được cấu hình cho phép',
 ];
diff --git a/lang/vi/settings.php b/lang/vi/settings.php
index 276891dc7..1ff252702 100644
--- a/lang/vi/settings.php
+++ b/lang/vi/settings.php
@@ -16,7 +16,7 @@ return [
     'app_customization' => 'Tuỳ biến',
     'app_features_security' => 'Chức năng & Bảo mật',
     'app_name' => 'Tên Ứng dụng',
-    'app_name_desc' => 'Tên này được hiển thị trong header và trong bất kì email hệ thống được gửi.',
+    'app_name_desc' => 'Tên này được hiển thị trong header và trong bất kỳ email hệ thống được gửi.',
     'app_name_header' => 'Hiển thị tên trong header',
     'app_public_access' => 'Quyền truy cập công khai',
     'app_public_access_desc' => 'Bật tùy chọn này sẽ cho phép khách, người không cần đăng nhập, truy cập đến nội dung bản BookStack của bạn.',
@@ -25,7 +25,7 @@ return [
     'app_public_viewing' => 'Cho phép xem công khai?',
     'app_secure_images' => 'Bảo mật tốt hơn cho việc tải lên ảnh',
     'app_secure_images_toggle' => 'Bật bảo mật tốt hơn cho các ảnh được tải lên',
-    'app_secure_images_desc' => 'Vì lí do hiệu năng, tất cả các ảnh đều được truy cập công khai. Tùy chọn này thêm một chuỗi ngẫu nhiên, khó đoán vào phần liên kết đến ảnh. Đảm bảo rằng tránh việc index thư mục để ngăn chặn việc truy cập đến ảnh một cách dễ dàng.',
+    'app_secure_images_desc' => 'Vì lý do hiệu năng, tất cả các ảnh đều được truy cập công khai. Tùy chọn này thêm một chuỗi ngẫu nhiên, khó đoán vào phần liên kết đến ảnh. Đảm bảo rằng tránh việc index thư mục để ngăn chặn việc truy cập đến ảnh một cách dễ dàng.',
     'app_default_editor' => 'Trình soạn thảo mặc định',
     'app_default_editor_desc' => 'Chọn trình soạn thảo nào sẽ được sử dụng theo mặc định khi chỉnh sửa trang mới. Điều này có thể bị ghi đè ở cấp độ trang nơi quyền cho phép.',
     'app_custom_html' => 'Tùy chọn nội dung Head HTML',
@@ -33,24 +33,24 @@ return [
     'app_custom_html_disabled_notice' => 'Nội dung tùy biến HTML head bị tắt tại trang cài đặt này để đảm bảo mọi thay đổi làm hỏng hệ thống có để được khôi phục.',
     'app_logo' => 'Logo Ứng dụng',
     'app_logo_desc' => 'Điều này được sử dụng trong thanh tiêu đề của ứng dụng, trong số những khu vực khác. Hình ảnh này nên có chiều cao 86px. Những hình ảnh lớn sẽ được thu nhỏ lại.',
-    'app_icon' => 'Giểu tượng ứng dụng',
+    'app_icon' => 'Biểu tượng ứng dụng',
     'app_icon_desc' => 'Biểu tượng này được sử dụng cho các tab trình duyệt và các biểu tượng phím tắt. Đây phải là hình ảnh PNG hình vuông 256px.',
     'app_homepage' => 'Trang chủ Ứng dụng',
     'app_homepage_desc' => 'Chọn hiển thị để hiện tại trang chủ thay cho hiển thị mặc định. Quyền cho trang được bỏ qua cho các trang được chọn.',
     'app_homepage_select' => 'Chọn một trang',
-    'app_footer_links' => 'Liên kết cuối trang',
+    'app_footer_links' => 'Liên kết chân trang',
     'app_footer_links_desc' => 'Thêm liên kết để hiển thị trong phần chân trang. Chúng sẽ được hiển thị ở dưới cùng của hầu hết các trang, bao gồm cả những trang không yêu cầu đăng nhập. Bạn có thể sử dụng nhãn "trans::<key>" để dùng bản dịch do hệ thống xác định. Ví dụ: Sử dụng "trans::common.privacy_policy" sẽ cung cấp văn bản được dịch là "Chính sách quyền riêng tư" và "trans::common.terms_of_service" sẽ cung cấp văn bản được dịch là "Điều khoản dịch vụ".',
     'app_footer_links_label' => 'Nhãn liên kết',
     'app_footer_links_url' => 'Địa chỉ liên kết',
-    'app_footer_links_add' => 'Thêm Footer Link',
+    'app_footer_links_add' => 'Thêm liên kết chân trang',
     'app_disable_comments' => 'Tắt bình luận',
     'app_disable_comments_toggle' => 'Tắt bình luận',
     'app_disable_comments_desc' => 'Tắt các bình luận trên tất cả các trang của ứng dụng. <br> Các bình luận đã tồn tại sẽ không được hiển thị.',
 
     // Color settings
-    'color_scheme' => 'Ứng dụng bảng màu',
-    'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.',
-    'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.',
+    'color_scheme' => 'Bảng màu ứng dụng',
+    'color_scheme_desc' => 'Đặt các màu được sử dụng trong giao diện người dùng của ứng dụng. Màu sắc có thể được cấu hình riêng cho chế độ tối và sáng để phù hợp nhất với chủ đề và đảm bảo khả năng đọc.',
+    'ui_colors_desc' => 'Đặt màu chính của ứng dụng và màu liên kết mặc định. Màu chính chủ yếu được sử dụng cho biểu ngữ đầu trang, nút và trang trí giao diện. Màu liên kết mặc định được sử dụng cho các liên kết và hành động dựa trên văn bản, cả trong nội dung đã viết và trong giao diện ứng dụng.',
     'app_color' => 'Màu cơ bản',
     'link_color' => 'Màu liên kết mặc định',
     'content_colors_desc' => 'Đặt màu cho tất cả các thành phần trong phân cấp tổ chức trang. Nên chọn màu có độ sáng tương tự với màu mặc định để có thể đọc được.',
@@ -64,8 +64,8 @@ return [
     'reg_settings' => 'Đăng ký',
     'reg_enable' => 'Bật Đăng ký',
     'reg_enable_toggle' => 'Bật đăng ký',
-    'reg_enable_desc' => 'Khi đăng ký được bật người dùng sẽ có thể tự đăng ký để trở thành người dùng của ứng dụng. Khi đăng kí người dùng sẽ được cấp một quyền sử dụng mặc định.',
-    'reg_default_role' => 'Quyền người dùng sử dụng mặc định sau khi đăng kí',
+    'reg_enable_desc' => 'Khi đăng ký được bật, người dùng sẽ có thể tự đăng ký để trở thành người dùng của ứng dụng. Khi đăng kí, người dùng sẽ được cấp một quyền sử dụng mặc định.',
+    'reg_default_role' => 'Quyền người dùng mặc định sau khi đăng kí',
     'reg_enable_external_warning' => 'Tùy chọn trên bị bỏ qua khi xác thực từ bên ngoài LDAP hoặc SAML được bật. Tài khoản người dùng chưa phải là thành viên sẽ được tự động tạo nếu xác thực với hệ thống bên ngoài thành công.',
     'reg_email_confirmation' => 'Xác nhận Email',
     'reg_email_confirmation_toggle' => 'Yêu cầu xác nhận email',
@@ -75,44 +75,44 @@ return [
     'reg_confirm_restrict_domain_placeholder' => 'Không có giới hạn nào được thiết lập',
 
     // Sorting Settings
-    'sorting' => 'Sorting',
-    'sorting_book_default' => 'Default Book Sort',
-    'sorting_book_default_desc' => 'Select the default sort rule to apply to new books. This won\'t affect existing books, and can be overridden per-book.',
-    'sorting_rules' => 'Sort Rules',
-    'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.',
-    'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books',
-    'sort_rule_create' => 'Create Sort Rule',
-    'sort_rule_edit' => 'Edit Sort Rule',
-    'sort_rule_delete' => 'Delete Sort Rule',
-    'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.',
-    'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?',
-    'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?',
-    'sort_rule_details' => 'Sort Rule Details',
-    'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.',
-    'sort_rule_operations' => 'Sort Operations',
-    'sort_rule_operations_desc' => 'Configure the sort actions to be performed by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom. Any changes made here will be applied to all assigned books upon save.',
-    'sort_rule_available_operations' => 'Available Operations',
-    'sort_rule_available_operations_empty' => 'No operations remaining',
-    'sort_rule_configured_operations' => 'Configured Operations',
-    'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list',
-    'sort_rule_op_asc' => '(Asc)',
-    'sort_rule_op_desc' => '(Desc)',
-    'sort_rule_op_name' => 'Name - Alphabetical',
-    'sort_rule_op_name_numeric' => 'Name - Numeric',
-    'sort_rule_op_created_date' => 'Created Date',
-    'sort_rule_op_updated_date' => 'Updated Date',
-    'sort_rule_op_chapters_first' => 'Chapters First',
-    'sort_rule_op_chapters_last' => 'Chapters Last',
+    'sorting' => 'Sắp xếp',
+    'sorting_book_default' => 'Sắp xếp sách mặc định',
+    'sorting_book_default_desc' => 'Chọn quy tắc sắp xếp mặc định để áp dụng cho sách mới. Điều này sẽ không ảnh hưởng đến các sách hiện có và có thể được ghi đè cho từng sách.',
+    'sorting_rules' => 'Quy tắc sắp xếp',
+    'sorting_rules_desc' => 'Đây là các thao tác sắp xếp được xác định trước có thể được áp dụng cho nội dung trong hệ thống.',
+    'sort_rule_assigned_to_x_books' => 'Được gán cho :count sách|Được gán cho :count sách',
+    'sort_rule_create' => 'Tạo quy tắc sắp xếp',
+    'sort_rule_edit' => 'Chỉnh sửa quy tắc sắp xếp',
+    'sort_rule_delete' => 'Xóa quy tắc sắp xếp',
+    'sort_rule_delete_desc' => 'Xóa quy tắc sắp xếp này khỏi hệ thống. Sách sử dụng quy tắc này sẽ trở lại sắp xếp thủ công.',
+    'sort_rule_delete_warn_books' => 'Quy tắc sắp xếp này hiện đang được sử dụng trên :count sách. Bạn có chắc chắn muốn xóa quy tắc này không?',
+    'sort_rule_delete_warn_default' => 'Quy tắc sắp xếp này hiện đang được sử dụng làm mặc định cho sách. Bạn có chắc chắn muốn xóa quy tắc này không?',
+    'sort_rule_details' => 'Chi tiết quy tắc sắp xếp',
+    'sort_rule_details_desc' => 'Đặt tên cho quy tắc sắp xếp này, tên này sẽ xuất hiện trong danh sách khi người dùng chọn một sắp xếp.',
+    'sort_rule_operations' => 'Thao tác sắp xếp',
+    'sort_rule_operations_desc' => 'Cấu hình các hành động sắp xếp sẽ được thực hiện bằng cách di chuyển chúng từ danh sách các thao tác khả dụng. Khi sử dụng, các thao tác sẽ được áp dụng theo thứ tự, từ trên xuống dưới. Bất kỳ thay đổi nào được thực hiện ở đây sẽ được áp dụng cho tất cả các sách được gán khi lưu.',
+    'sort_rule_available_operations' => 'Thao tác khả dụng',
+    'sort_rule_available_operations_empty' => 'Không còn thao tác nào',
+    'sort_rule_configured_operations' => 'Thao tác đã cấu hình',
+    'sort_rule_configured_operations_empty' => 'Kéo/thêm thao tác từ danh sách "Thao tác khả dụng"',
+    'sort_rule_op_asc' => '(Tăng dần)',
+    'sort_rule_op_desc' => '(Giảm dần)',
+    'sort_rule_op_name' => 'Tên - Theo bảng chữ cái',
+    'sort_rule_op_name_numeric' => 'Tên - Theo số',
+    'sort_rule_op_created_date' => 'Ngày tạo',
+    'sort_rule_op_updated_date' => 'Ngày cập nhật',
+    'sort_rule_op_chapters_first' => 'Chương trước',
+    'sort_rule_op_chapters_last' => 'Chương sau',
 
     // Maintenance settings
     'maint' => 'Bảo trì',
     'maint_image_cleanup' => 'Dọn dẹp ảnh',
-    'maint_image_cleanup_desc' => 'Quét nội dung trang và phiên bản để kiểm tra xem các ảnh và hình vẽ nào đang được sử dụng và ảnh nào dư thừa. Đảm bảo rằng bạn đã tạo bản sao lưu toàn dữ liệu và ảnh trước khi chạy chức năng này.',
+    'maint_image_cleanup_desc' => 'Quét nội dung trang và phiên bản để kiểm tra xem các ảnh và hình vẽ nào đang được sử dụng và ảnh nào dư thừa. Đảm bảo rằng bạn đã tạo bản sao lưu toàn bộ dữ liệu và ảnh trước khi chạy chức năng này.',
     'maint_delete_images_only_in_revisions' => 'Cũng xóa hình ảnh chỉ tồn tại trong các phiên bản trang cũ',
     'maint_image_cleanup_run' => 'Chạy Dọn dẹp',
     'maint_image_cleanup_warning' => 'Đã tìm thấy :count ảnh có thể không được sử dụng. Bạn muốn chắc rằng muốn xóa các ảnh này?',
     'maint_image_cleanup_success' => ':count ảnh có thể không được sử dụng đã được tìm thấy và xóa!',
-    'maint_image_cleanup_nothing_found' => 'Không tìm thấy ảnh nào không được xử dụng, Không có gì để xóa!',
+    'maint_image_cleanup_nothing_found' => 'Không tìm thấy ảnh nào không được sử dụng, Không có gì để xóa!',
     'maint_send_test_email' => 'Gửi một email thử',
     'maint_send_test_email_desc' => 'Chức năng này gửi một email thử đến địa chỉ email bạn chỉ định trong hồ sơ của mình.',
     'maint_send_test_email_run' => 'Gửi email thử',
@@ -139,26 +139,26 @@ return [
     'recycle_bin_contents_empty' => 'Thùng rác hiện đang trống',
     'recycle_bin_empty' => 'Dọn dẹp Thùng Rác',
     'recycle_bin_empty_confirm' => 'Thao tác này sẽ hủy vĩnh viễn tất cả các mục trong thùng rác bao gồm cả nội dung có trong mỗi mục. Bạn có chắc chắn muốn làm trống thùng rác?',
-    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
-    'recycle_bin_destroy_list' => 'Items to be Destroyed',
-    'recycle_bin_restore_list' => 'Items to be Restored',
-    'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
-    'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
-    'recycle_bin_restore_parent' => 'Restore Parent',
-    'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
-    'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
+    'recycle_bin_destroy_confirm' => 'Hành động này sẽ xóa vĩnh viễn mục này khỏi hệ thống, cùng với bất kỳ phần tử con nào được liệt kê bên dưới và bạn sẽ không thể khôi phục nội dung này. Bạn có chắc chắn muốn xóa vĩnh viễn mục này không?',
+    'recycle_bin_destroy_list' => 'Các mục sẽ bị hủy',
+    'recycle_bin_restore_list' => 'Các mục sẽ được khôi phục',
+    'recycle_bin_restore_confirm' => 'Hành động này sẽ khôi phục mục đã xóa, bao gồm mọi phần tử con, về vị trí ban đầu của chúng. Nếu vị trí ban đầu đã bị xóa và hiện đang nằm trong thùng rác, mục cha cũng sẽ cần được khôi phục.',
+    'recycle_bin_restore_deleted_parent' => 'Mục cha của mục này cũng đã bị xóa. Chúng sẽ vẫn bị xóa cho đến khi mục cha đó cũng được khôi phục.',
+    'recycle_bin_restore_parent' => 'Khôi phục mục cha',
+    'recycle_bin_destroy_notification' => 'Đã xóa :count tổng số mục khỏi thùng rác.',
+    'recycle_bin_restore_notification' => 'Đã khôi phục :count tổng số mục khỏi thùng rác.',
 
     // Audit Log
-    'audit' => 'Audit Log',
-    'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'audit_event_filter' => 'Event Filter',
+    'audit' => 'Nhật ký kiểm tra',
+    'audit_desc' => 'Nhật ký kiểm tra này hiển thị danh sách các hoạt động được theo dõi trong hệ thống. Danh sách này không được lọc không giống như các danh sách hoạt động tương tự trong hệ thống nơi các bộ lọc quyền được áp dụng.',
+    'audit_event_filter' => 'Bộ lọc sự kiện',
     'audit_event_filter_no_filter' => 'Không Lọc',
     'audit_deleted_item' => 'Mục Đã Xóa',
     'audit_deleted_item_name' => 'Tên: :name',
     'audit_table_user' => 'Người dùng',
     'audit_table_event' => 'Sự kiện',
-    'audit_table_related' => 'Related Item or Detail',
-    'audit_table_ip' => 'IP Address',
+    'audit_table_related' => 'Mục hoặc chi tiết liên quan',
+    'audit_table_ip' => 'Địa chỉ IP',
     'audit_table_date' => 'Ngày hoạt động',
     'audit_date_from' => 'Ngày từ khoảng',
     'audit_date_to' => 'Ngày đến khoảng',
@@ -166,22 +166,22 @@ return [
     // Role Settings
     'roles' => 'Quyền',
     'role_user_roles' => 'Quyền người dùng',
-    'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.',
-    'roles_x_users_assigned' => ':count user assigned|:count users assigned',
-    'roles_x_permissions_provided' => ':count permission|:count permissions',
-    'roles_assigned_users' => 'Assigned Users',
-    'roles_permissions_provided' => 'Provided Permissions',
+    'roles_index_desc' => 'Các quyền được sử dụng để nhóm người dùng và cung cấp quyền hệ thống cho các thành viên của họ. Khi một người dùng là thành viên của nhiều quyền, các đặc quyền được cấp sẽ chồng lên nhau và người dùng sẽ thừa hưởng tất cả các khả năng.',
+    'roles_x_users_assigned' => ':count người dùng được gán|:count người dùng được gán',
+    'roles_x_permissions_provided' => ':count quyền|:count quyền',
+    'roles_assigned_users' => 'Người dùng được gán',
+    'roles_permissions_provided' => 'Quyền được cung cấp',
     'role_create' => 'Tạo quyền mới',
     'role_delete' => 'Xóa quyền',
     'role_delete_confirm' => 'Chức năng này sẽ xóa quyền với tên \':roleName\'.',
     'role_delete_users_assigned' => 'Quyền này có :userCount người dùng được gán. Nếu bạn muốn di dời các người dùng từ quyền này hãy chọn một quyền mới bên dưới.',
-    'role_delete_no_migration' => "Không di dời các người dùng",
+    'role_delete_no_migration' => "Không di dời người dùng",
     'role_delete_sure' => 'Bạn có chắc rằng muốn xóa quyền này?',
     'role_edit' => 'Sửa quyền',
     'role_details' => 'Thông tin chi tiết Quyền',
     'role_name' => 'Tên quyền',
     'role_desc' => 'Thông tin vắn tắt của Quyền',
-    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_mfa_enforced' => 'Yêu cầu xác thực đa yếu tố',
     'role_external_auth_id' => 'Mã của xác thực ngoài',
     'role_system' => 'Quyền Hệ thống',
     'role_manage_users' => 'Quản lý người dùng',
@@ -191,15 +191,15 @@ return [
     'role_manage_page_templates' => 'Quản lý các mẫu trang',
     'role_access_api' => 'Truy cập đến API hệ thống',
     'role_manage_settings' => 'Quản lý cài đặt của ứng dụng',
-    'role_export_content' => 'Export content',
-    'role_import_content' => 'Import content',
-    'role_editor_change' => 'Change page editor',
-    'role_notifications' => 'Receive & manage notifications',
+    'role_export_content' => 'Xuất nội dung',
+    'role_import_content' => 'Nhập nội dung',
+    'role_editor_change' => 'Thay đổi trình soạn thảo trang',
+    'role_notifications' => 'Nhận & quản lý thông báo',
     'role_asset' => 'Quyền tài sản (asset)',
-    'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
-    'role_asset_desc' => 'Các quyền này điều khiển truy cập mặc định tới tài sản (asset) nằm trong hệ thống. Quyền tại Sách, Chường và Trang se ghi đè các quyền này.',
+    'roles_system_warning' => 'Cần lưu ý rằng việc truy cập vào bất kỳ ba quyền trên có thể cho phép người dùng thay đổi đặc quyền của chính họ hoặc đặc quyền của những người khác trong hệ thống. Chỉ gán các vai trò có các quyền này cho những người dùng đáng tin cậy.',
+    'role_asset_desc' => 'Các quyền này điều khiển truy cập mặc định tới tài sản (asset) nằm trong hệ thống. Quyền tại Sách, Chương và Trang sẽ ghi đè các quyền này.',
     'role_asset_admins' => 'Quản trị viên được tự động cấp quyền truy cập đến toàn bộ nội dung, tuy nhiên các tùy chọn đó có thể hiện hoặc ẩn tùy chọn giao diện.',
-    'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.',
+    'role_asset_image_view_note' => 'Điều này liên quan đến khả năng hiển thị trong trình quản lý hình ảnh. Quyền truy cập thực tế vào các tệp hình ảnh đã tải lên sẽ phụ thuộc vào tùy chọn lưu trữ hình ảnh của hệ thống.',
     'role_all' => 'Tất cả',
     'role_own' => 'Sở hữu',
     'role_controlled_by_asset' => 'Kiểm soát các tài sản (asset) người dùng tải lên',
@@ -209,7 +209,7 @@ return [
 
     // Users
     'users' => 'Người dùng',
-    'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.',
+    'users_index_desc' => 'Tạo và quản lý các tài khoản người dùng riêng lẻ trong hệ thống. Các tài khoản người dùng được sử dụng để đăng nhập và gán nội dung & hoạt động. Quyền truy cập chủ yếu dựa trên vai trò nhưng quyền sở hữu nội dung của người dùng, cùng với các yếu tố khác, cũng có thể ảnh hưởng đến quyền và truy cập.',
     'user_profile' => 'Hồ sơ người dùng',
     'users_add_new' => 'Thêm người dùng mới',
     'users_search' => 'Tìm kiếm người dùng',
@@ -220,45 +220,45 @@ return [
     'users_role' => 'Quyền người dùng',
     'users_role_desc' => 'Chọn quyền mà người dùng sẽ được gán. Nếu người dùng được gán nhiều quyền, các quyền hạn sẽ ghi đè lên nhau và họ sẽ nhận được tất cả các quyền hạn từ quyền được gán.',
     'users_password' => 'Mật khẩu người dùng',
-    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
+    'users_password_desc' => 'Đặt mật khẩu được sử dụng để đăng nhập vào ứng dụng. Mật khẩu phải dài ít nhất 8 ký tự.',
     'users_send_invite_text' => 'Bạn có thể chọn để gửi cho người dùng này một email mời, giúp họ có thể tự đặt mật khẩu cho chính họ. Nếu không bạn có thể đặt mật khẩu cho họ.',
     'users_send_invite_option' => 'Gửi email mời người dùng',
     'users_external_auth_id' => 'Mã của xác thực ngoài',
-    'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.',
-    'users_password_warning' => 'Only fill the below if you would like to change the password for this user.',
+    'users_external_auth_id_desc' => 'Khi một hệ thống xác thực bên ngoài đang được sử dụng (chẳng hạn như SAML2, OIDC hoặc LDAP) đây là ID liên kết người dùng BookStack này với tài khoản hệ thống xác thực. Bạn có thể bỏ qua trường này nếu sử dụng xác thực dựa trên email mặc định.',
+    'users_password_warning' => 'Chỉ điền vào phần bên dưới nếu bạn muốn thay đổi mật khẩu cho người dùng này.',
     'users_system_public' => 'Người dùng này đại diện cho bất kỳ khách nào thăm trang của bạn. Nó được tự động gán và không thể dùng để đăng nhập.',
     'users_delete' => 'Xóa Người dùng',
     'users_delete_named' => 'Xóa người dùng :userName',
-    'users_delete_warning' => 'Chức năng này sẽ hoàn toàn xóa người dùng với tên \':userName\' từ hệ thống.',
+    'users_delete_warning' => 'Chức năng này sẽ hoàn toàn xóa người dùng với tên \':userName\' khỏi hệ thống.',
     'users_delete_confirm' => 'Bạn có chắc muốn xóa người dùng này không?',
-    'users_migrate_ownership' => 'Migrate Ownership',
-    'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.',
+    'users_migrate_ownership' => 'Di chuyển quyền sở hữu',
+    'users_migrate_ownership_desc' => 'Chọn một người dùng tại đây nếu bạn muốn một người dùng khác trở thành chủ sở hữu của tất cả các mục hiện thuộc sở hữu của người dùng này.',
     'users_none_selected' => 'Chưa chọn người dùng',
     'users_edit' => 'Sửa người dùng',
     'users_edit_profile' => 'Sửa Hồ sơ',
     'users_avatar' => 'Ảnh đại diện',
-    'users_avatar_desc' => 'Chọn ảnh đê đại hiện cho người dùng này. Ảnh nên có kích cỡ hình vuông 256px.',
+    'users_avatar_desc' => 'Chọn ảnh để đại diện cho người dùng này. Ảnh nên có kích cỡ hình vuông 256px.',
     'users_preferred_language' => 'Ngôn ngữ ưu tiên',
-    'users_preferred_language_desc' => 'Tùy chọn này sẽ thay đổi ngôn ngư sử dụng cho giao diện người dùng của ứng dụng. Nó sẽ không ảnh hưởng đến bất cứ nội dung nào người dùng tạo ra.',
+    'users_preferred_language_desc' => 'Tùy chọn này sẽ thay đổi ngôn ngữ sử dụng cho giao diện người dùng của ứng dụng. Nó sẽ không ảnh hưởng đến bất cứ nội dung nào người dùng tạo ra.',
     'users_social_accounts' => 'Tài khoản MXH',
-    'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.',
-    'users_social_accounts_info' => 'Bạn có thể kết nối đến các tài khoản khác để đăng nhập nhanh chóng và dễ dàng. Ngắt kết nối đến một tài khoản ở đây không thu hồi việc ủy quyền truy cập trước đó. Thu hồi truy cập của các tài khoản kết nối MXH từ trang cài đặt hồ sở của bạn.',
+    'users_social_accounts_desc' => 'Xem trạng thái của các tài khoản xã hội được kết nối cho người dùng này. Các tài khoản xã hội có thể được sử dụng ngoài hệ thống xác thực chính để truy cập hệ thống.',
+    'users_social_accounts_info' => 'Bạn có thể kết nối đến các tài khoản khác để đăng nhập nhanh chóng và dễ dàng. Ngắt kết nối đến một tài khoản ở đây không thu hồi việc ủy quyền truy cập trước đó. Thu hồi truy cập của các tài khoản kết nối MXH từ trang cài đặt hồ sơ của bạn.',
     'users_social_connect' => 'Kết nối tài khoản',
     'users_social_disconnect' => 'Ngắt kết nối tài khoản',
-    'users_social_status_connected' => 'Connected',
-    'users_social_status_disconnected' => 'Disconnected',
+    'users_social_status_connected' => 'Đã kết nối',
+    'users_social_status_disconnected' => 'Đã ngắt kết nối',
     'users_social_connected' => 'Tài khoản :socialAccount đã được liên kết với hồ sơ của bạn thành công.',
     'users_social_disconnected' => 'Tài khoản :socialAccount đã được ngắt kết nối khỏi hồ sơ của bạn thành công.',
     'users_api_tokens' => 'Các Token API',
-    'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.',
-    'users_api_tokens_none' => 'Khong có Token API nào được tạo cho người dùng này',
+    'users_api_tokens_desc' => 'Tạo và quản lý các mã thông báo truy cập được sử dụng để xác thực với API REST của BookStack. Quyền cho API được quản lý thông qua người dùng mà mã thông báo thuộc về.',
+    'users_api_tokens_none' => 'Không có Token API nào được tạo cho người dùng này',
     'users_api_tokens_create' => 'Tạo Token',
     'users_api_tokens_expires' => 'Hết hạn',
     'users_api_tokens_docs' => 'Tài liệu API',
-    'users_mfa' => 'Multi-Factor Authentication',
-    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
-    'users_mfa_x_methods' => ':count method configured|:count methods configured',
-    'users_mfa_configure' => 'Configure Methods',
+    'users_mfa' => 'Xác thực đa yếu tố',
+    'users_mfa_desc' => 'Thiết lập xác thực đa yếu tố như một lớp bảo mật bổ sung cho tài khoản người dùng của bạn.',
+    'users_mfa_x_methods' => ':count phương thức đã cấu hình|:count phương thức đã cấu hình',
+    'users_mfa_configure' => 'Cấu hình phương thức',
 
     // API Tokens
     'user_api_token_create' => 'Tạo Token API',
@@ -275,46 +275,46 @@ return [
     'user_api_token_created' => 'Token được tạo :timeAgo',
     'user_api_token_updated' => 'Token được cập nhật :timeAgo',
     'user_api_token_delete' => 'Xóa Token',
-    'user_api_token_delete_warning' => 'Chức năng này sẽ hoàn toàn xóa token API với tên \':tokenName\' từ hệ thống.',
+    'user_api_token_delete_warning' => 'Chức năng này sẽ hoàn toàn xóa token API với tên \':tokenName\' khỏi hệ thống.',
     'user_api_token_delete_confirm' => 'Bạn có chắc rằng muốn xóa token API này?',
 
     // Webhooks
     'webhooks' => 'Webhooks',
-    'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.',
-    'webhooks_x_trigger_events' => ':count trigger event|:count trigger events',
+    'webhooks_index_desc' => 'Webhooks là một cách để gửi dữ liệu đến các URL bên ngoài khi một số hành động và sự kiện nhất định xảy ra trong hệ thống, cho phép tích hợp dựa trên sự kiện với các nền tảng bên ngoài như hệ thống nhắn tin hoặc thông báo.',
+    'webhooks_x_trigger_events' => ':count sự kiện kích hoạt|:count sự kiện kích hoạt',
     'webhooks_create' => 'Tạo Webhook mới',
     'webhooks_none_created' => 'Chưa có webhooks nào được tạo.',
     'webhooks_edit' => 'Chỉnh sửa Webhook',
     'webhooks_save' => 'Lưu Webhook',
     'webhooks_details' => 'Chi tiết Webhook',
-    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
-    'webhooks_events' => 'Webhook Events',
-    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
-    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
-    'webhooks_events_all' => 'All system events',
+    'webhooks_details_desc' => 'Cung cấp một tên thân thiện với người dùng và một điểm cuối POST làm vị trí để dữ liệu webhook được gửi đến.',
+    'webhooks_events' => 'Sự kiện Webhook',
+    'webhooks_events_desc' => 'Chọn tất cả các sự kiện sẽ kích hoạt webhook này được gọi.',
+    'webhooks_events_warning' => 'Hãy nhớ rằng các sự kiện này sẽ được kích hoạt cho tất cả các sự kiện đã chọn, ngay cả khi các quyền tùy chỉnh được áp dụng. Đảm bảo rằng việc sử dụng webhook này sẽ không làm lộ nội dung bí mật.',
+    'webhooks_events_all' => 'Tất cả các sự kiện hệ thống',
     'webhooks_name' => 'Tên Webhook',
-    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
-    'webhooks_endpoint' => 'Webhook Endpoint',
-    'webhooks_active' => 'Webhook Active',
-    'webhook_events_table_header' => 'Events',
-    'webhooks_delete' => 'Delete Webhook',
-    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
-    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
-    'webhooks_format_example' => 'Webhook Format Example',
-    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_timeout' => 'Thời gian chờ yêu cầu Webhook (Giây)',
+    'webhooks_endpoint' => 'Điểm cuối Webhook',
+    'webhooks_active' => 'Webhook hoạt động',
+    'webhook_events_table_header' => 'Sự kiện',
+    'webhooks_delete' => 'Xóa Webhook',
+    'webhooks_delete_warning' => 'Điều này sẽ xóa hoàn toàn webhook này, với tên \':webhookName\', khỏi hệ thống.',
+    'webhooks_delete_confirm' => 'Bạn có chắc chắn muốn xóa webhook này không?',
+    'webhooks_format_example' => 'Ví dụ định dạng Webhook',
+    'webhooks_format_example_desc' => 'Dữ liệu webhook được gửi dưới dạng yêu cầu POST đến điểm cuối đã cấu hình dưới dạng JSON theo định dạng bên dưới. Các thuộc tính "related_item" và "url" là tùy chọn và sẽ phụ thuộc vào loại sự kiện được kích hoạt.',
     'webhooks_status' => 'Trạng thái Webhook',
     'webhooks_last_called' => 'Lần cuối được gọi:',
     'webhooks_last_errored' => 'Lần cuối xảy ra lỗi:',
     'webhooks_last_error_message' => 'Nội dung lỗi gần nhất:',
 
     // Licensing
-    'licenses' => 'Licenses',
-    'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.',
-    'licenses_bookstack' => 'BookStack License',
-    'licenses_php' => 'PHP Library Licenses',
-    'licenses_js' => 'JavaScript Library Licenses',
-    'licenses_other' => 'Other Licenses',
-    'license_details' => 'License Details',
+    'licenses' => 'Giấy phép',
+    'licenses_desc' => 'Trang này trình bày chi tiết thông tin giấy phép cho BookStack ngoài các dự án & thư viện được sử dụng trong BookStack. Nhiều dự án được liệt kê có thể chỉ được sử dụng trong ngữ cảnh phát triển.',
+    'licenses_bookstack' => 'Giấy phép BookStack',
+    'licenses_php' => 'Giấy phép thư viện PHP',
+    'licenses_js' => 'Giấy phép thư viện JavaScript',
+    'licenses_other' => 'Các giấy phép khác',
+    'license_details' => 'Chi tiết giấy phép',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/vi/validation.php b/lang/vi/validation.php
index 9683c0857..bc6fc0e39 100644
--- a/lang/vi/validation.php
+++ b/lang/vi/validation.php
@@ -105,10 +105,10 @@ return [
     'url'                  => 'Định dạng của :attribute không hợp lệ.',
     'uploaded'             => 'Tệp tin đã không được tải lên. Máy chủ không chấp nhận các tệp tin với dung lượng lớn như tệp tin trên.',
 
-    'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
-    'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
-    'zip_model_expected' => 'Data object expected but ":type" found.',
-    'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
+    'zip_file' => ':attribute cần tham chiếu đến một tệp trong ZIP.',
+    'zip_file_mime' => ':attribute cần tham chiếu đến một tệp có kiểu: :validTypes, tìm thấy :foundType.',
+    'zip_model_expected' => 'Đối tượng dữ liệu được mong đợi nhưng tìm thấy ":type".',
+    'zip_unique' => ':attribute phải là duy nhất cho kiểu đối tượng trong ZIP.',
 
     // Custom validation lines
     'custom' => [
diff --git a/lang/zh_CN/common.php b/lang/zh_CN/common.php
index b1be3c73f..dde4bdf7d 100644
--- a/lang/zh_CN/common.php
+++ b/lang/zh_CN/common.php
@@ -30,6 +30,8 @@ return [
     'create' => '创建',
     'update' => '更新',
     'edit' => '编辑',
+    'archive' => '存档',
+    'unarchive' => '取消存档',
     'sort' => '排序',
     'move' => '移动',
     'copy' => '复制',
diff --git a/lang/zh_CN/editor.php b/lang/zh_CN/editor.php
index 8eb335ea2..6dbca56e3 100644
--- a/lang/zh_CN/editor.php
+++ b/lang/zh_CN/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => '上标',
     'subscript' => '下标',
     'text_color' => '文本颜色',
+    'highlight_color' => 'Highlight color',
     'custom_color' => '自定义颜色',
     'remove_color' => '移除颜色',
     'background_color' => '背景色',
diff --git a/lang/zh_CN/entities.php b/lang/zh_CN/entities.php
index f7d22973a..03f6393ca 100644
--- a/lang/zh_CN/entities.php
+++ b/lang/zh_CN/entities.php
@@ -46,7 +46,7 @@ return [
     'import' => '导入',
     'import_validate' => '验证导入',
     'import_desc' => '使用便携式 zip 导出从相同或不同的实例导入书籍、章节和页面。选择一个 ZIP 文件以继续。文件上传并验证后，您就可以在下一个视图中配置和确认导入。',
-    'import_zip_select' => '选择要上床的 ZIP 文件',
+    'import_zip_select' => '选择要上传的 ZIP 文件',
     'import_zip_validation_errors' => '验证提供的 ZIP 文件时检测到错误:',
     'import_pending' => '等待导入',
     'import_pending_none' => '尚未开始导入。',
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => '这将删除上传的ZIP文件，不能撤消。',
     'import_errors' => '导入错误',
     'import_errors_desc' => '在尝试导入过程中出现了以下错误:',
+    'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
+    'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
+    'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
+    'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
 
     // Permissions and restrictions
     'permissions' => '权限',
@@ -166,9 +170,9 @@ return [
     'books_search_this' => '搜索这本书',
     'books_navigation' => '图书导航',
     'books_sort' => '排序图书内容',
-    'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.',
+    'books_sort_desc' => '在书籍内部移动章节与页面以重组内容；支持添加其他书籍，实现跨书籍便捷移动章节与页面；还可设置自动排序规则，在内容发生变更时自动对本书内容进行排序。',
     'books_sort_auto_sort' => '自动排序选项',
-    'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName',
+    'books_sort_auto_sort_active' => '自动排序已激活：::sortName',
     'books_sort_named' => '排序图书「:bookName」',
     'books_sort_name' => '按名称排序',
     'books_sort_created' => '创建时间排序',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '（保留内容）',
     'pages_edit_switch_to_wysiwyg' => '切换到所见即所得编辑器',
     'pages_edit_switch_to_new_wysiwyg' => '切换到新的所见即所得',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '(Alpha 测试)',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '（正在Beta测试中）',
     'pages_edit_set_changelog' => '更新说明',
     'pages_edit_enter_changelog_desc' => '输入对您所做更改的简要说明',
     'pages_edit_enter_changelog' => '输入更新说明',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => '插入图表',
     'pages_md_show_preview' => '显示预览',
     'pages_md_sync_scroll' => '同步预览滚动',
+    'pages_md_plain_editor' => '纯文本编辑器',
     'pages_drawing_unsaved' => '找到未保存的绘图',
     'pages_drawing_unsaved_confirm' => '从之前保存失败的绘图中发现了可恢复的数据。您想恢复并继续编辑这个未保存的绘图吗？',
     'pages_not_in_chapter' => '本页面不在某章节中',
@@ -392,8 +397,11 @@ return [
     'comment' => '评论',
     'comments' => '评论',
     'comment_add' => '添加评论',
+    'comment_none' => '没有要显示的评论',
     'comment_placeholder' => '在这里评论',
-    'comment_count' => '{0} 无评论|{1} 1 条评论|[2,*] :count 条评论',
+    'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
+    'comment_archived_count' => ':count 条评论已存档',
+    'comment_archived_threads' => '已存档的贴子',
     'comment_save' => '保存评论',
     'comment_new' => '新评论',
     'comment_created' => '评论于 :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => '评论已删除',
     'comment_created_success' => '评论已添加',
     'comment_updated_success' => '评论已更新',
+    'comment_archive_success' => '评论已存档',
+    'comment_unarchive_success' => '评论已取消存档',
+    'comment_view' => '查看评论',
+    'comment_jump_to_thread' => '跳转到贴子',
     'comment_delete_confirm' => '您确定要删除这条评论？',
     'comment_in_reply_to' => '回复 :commentId',
+    'comment_reference' => '参考',
+    'comment_reference_outdated' => '（已过时）',
     'comment_editor_explain' => '这里是此页面上留下的评论。查看已保存的页面时可以添加和管理评论。',
 
     // Revision
diff --git a/lang/zh_CN/settings.php b/lang/zh_CN/settings.php
index bc52b4ccc..2114c3648 100644
--- a/lang/zh_CN/settings.php
+++ b/lang/zh_CN/settings.php
@@ -348,6 +348,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => '挪威语 (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/lang/zh_TW/activities.php b/lang/zh_TW/activities.php
index d33e4d4b7..65ba0a565 100644
--- a/lang/zh_TW/activities.php
+++ b/lang/zh_TW/activities.php
@@ -47,7 +47,7 @@ return [
     'bookshelf_update'                 => '更新書棧',
     'bookshelf_update_notification'    => '書棧已更新',
     'bookshelf_delete'                 => '刪除書棧',
-    'bookshelf_delete_notification'    => '書棧已刪除',
+    'bookshelf_delete_notification'    => '書架已刪除',
 
     // Revisions
     'revision_restore' => '還原的版本',
diff --git a/lang/zh_TW/common.php b/lang/zh_TW/common.php
index f8023bdd5..46e749fd6 100644
--- a/lang/zh_TW/common.php
+++ b/lang/zh_TW/common.php
@@ -30,6 +30,8 @@ return [
     'create' => '建立',
     'update' => '更新',
     'edit' => '編輯',
+    'archive' => '歸檔',
+    'unarchive' => '取消封存',
     'sort' => '排序',
     'move' => '移動',
     'copy' => '複製',
diff --git a/lang/zh_TW/editor.php b/lang/zh_TW/editor.php
index 9319e64a4..c56f33efc 100644
--- a/lang/zh_TW/editor.php
+++ b/lang/zh_TW/editor.php
@@ -48,6 +48,7 @@ return [
     'superscript' => '上標',
     'subscript' => '下標',
     'text_color' => '文本顏色',
+    'highlight_color' => '突顯顏色',
     'custom_color' => '自訂顏色',
     'remove_color' => '移除颜色',
     'background_color' => '背景顏色',
diff --git a/lang/zh_TW/entities.php b/lang/zh_TW/entities.php
index d3a684f8f..46bbe62f9 100644
--- a/lang/zh_TW/entities.php
+++ b/lang/zh_TW/entities.php
@@ -63,6 +63,10 @@ return [
     'import_delete_desc' => '這將會刪除已上傳的匯入 ZIP 檔案，且無法還原。',
     'import_errors' => '匯入錯誤',
     'import_errors_desc' => '嘗試匯入時發生以下錯誤：',
+    'breadcrumb_siblings_for_page' => '瀏覽同層級頁面',
+    'breadcrumb_siblings_for_chapter' => '瀏覽同章節頁面',
+    'breadcrumb_siblings_for_book' => '瀏覽同書籍頁面',
+    'breadcrumb_siblings_for_bookshelf' => '瀏覽同書架頁面',
 
     // Permissions and restrictions
     'permissions' => '權限',
@@ -248,7 +252,7 @@ return [
     'pages_edit_switch_to_markdown_stable' => '(保留內容)',
     'pages_edit_switch_to_wysiwyg' => '切換到所見即所得編輯器',
     'pages_edit_switch_to_new_wysiwyg' => '切換為新的所見即所得編輯器',
-    'pages_edit_switch_to_new_wysiwyg_desc' => '（仍在開發測試階段）',
+    'pages_edit_switch_to_new_wysiwyg_desc' => '（仍在測試中）',
     'pages_edit_set_changelog' => '設定變更日誌',
     'pages_edit_enter_changelog_desc' => '輸入對您所做變動的簡易描述',
     'pages_edit_enter_changelog' => '輸入變更日誌',
@@ -268,6 +272,7 @@ return [
     'pages_md_insert_drawing' => '插入繪圖',
     'pages_md_show_preview' => '顯示預覽',
     'pages_md_sync_scroll' => '預覽頁面同步捲動',
+    'pages_md_plain_editor' => '純文字編輯器',
     'pages_drawing_unsaved' => '偵測到未儲存的繪圖',
     'pages_drawing_unsaved_confirm' => '從之前保存失敗的繪圖中發現了可恢復的數據。您想恢復並繼續編輯這個未保存的繪圖嗎？',
     'pages_not_in_chapter' => '頁面不在章節中',
@@ -392,8 +397,11 @@ return [
     'comment' => '評論',
     'comments' => '評論',
     'comment_add' => '新增評論',
+    'comment_none' => '無可顯示的留言',
     'comment_placeholder' => '在這裡評論',
-    'comment_count' => '{0} 無評論 |{1} :count 則評論 |[2,*] :count 則評論',
+    'comment_thread_count' => ':count 個留言討論串|:count 個留言討論串',
+    'comment_archived_count' => ':count 個已封存',
+    'comment_archived_threads' => '已封存的討論串',
     'comment_save' => '儲存評論',
     'comment_new' => '新評論',
     'comment_created' => '評論於 :createDiff',
@@ -402,8 +410,14 @@ return [
     'comment_deleted_success' => '評論已刪除',
     'comment_created_success' => '評論已加入',
     'comment_updated_success' => '評論已更新',
+    'comment_archive_success' => '留言已封存',
+    'comment_unarchive_success' => '留言已解除封存',
+    'comment_view' => '檢視留言',
+    'comment_jump_to_thread' => '前往討論串',
     'comment_delete_confirm' => '您確定要刪除這則評論？',
     'comment_in_reply_to' => '回覆 :commentId',
+    'comment_reference' => '參考資料',
+    'comment_reference_outdated' => '（過期）',
     'comment_editor_explain' => '此為頁面上的評論。在查看檢視與儲存頁面的同時，可以新增和管理評論。',
 
     // Revision
diff --git a/lang/zh_TW/errors.php b/lang/zh_TW/errors.php
index 7a42ce511..d4239ed6c 100644
--- a/lang/zh_TW/errors.php
+++ b/lang/zh_TW/errors.php
@@ -67,7 +67,7 @@ return [
 
     // Entities
     'entity_not_found' => '找不到實體',
-    'bookshelf_not_found' => '未找到書棧',
+    'bookshelf_not_found' => '未找到書架',
     'book_not_found' => '找不到書本',
     'page_not_found' => '找不到頁面',
     'chapter_not_found' => '找不到章節',
diff --git a/lang/zh_TW/settings.php b/lang/zh_TW/settings.php
index ec2682cf1..752d3f2a7 100644
--- a/lang/zh_TW/settings.php
+++ b/lang/zh_TW/settings.php
@@ -349,6 +349,7 @@ return [
         'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nb' => 'Norsk (Bokmål)',
+        'ne' => 'नेपाली',
         'nn' => 'Nynorsk',
         'nl' => 'Nederlands',
         'pl' => 'Polski',
diff --git a/package-lock.json b/package-lock.json
index d00dceac2..b85d1f5e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,46 +5,47 @@
   "packages": {
     "": {
       "dependencies": {
-        "@codemirror/commands": "^6.8.0",
+        "@codemirror/commands": "^6.10.0",
         "@codemirror/lang-css": "^6.3.1",
-        "@codemirror/lang-html": "^6.4.9",
-        "@codemirror/lang-javascript": "^6.2.3",
-        "@codemirror/lang-json": "^6.0.1",
-        "@codemirror/lang-markdown": "^6.3.2",
-        "@codemirror/lang-php": "^6.0.1",
+        "@codemirror/lang-html": "^6.4.11",
+        "@codemirror/lang-javascript": "^6.2.4",
+        "@codemirror/lang-json": "^6.0.2",
+        "@codemirror/lang-markdown": "^6.5.0",
+        "@codemirror/lang-php": "^6.0.2",
         "@codemirror/lang-xml": "^6.1.0",
-        "@codemirror/language": "^6.10.8",
-        "@codemirror/legacy-modes": "^6.4.3",
+        "@codemirror/language": "^6.11.3",
+        "@codemirror/legacy-modes": "^6.5.2",
         "@codemirror/state": "^6.5.2",
-        "@codemirror/theme-one-dark": "^6.1.2",
-        "@codemirror/view": "^6.36.3",
-        "@lezer/highlight": "^1.2.1",
+        "@codemirror/theme-one-dark": "^6.1.3",
+        "@codemirror/view": "^6.38.8",
+        "@lezer/highlight": "^1.2.3",
         "@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
         "@ssddanbrown/codemirror-lang-twig": "^1.0.0",
-        "@types/jest": "^29.5.14",
-        "codemirror": "^6.0.1",
-        "idb-keyval": "^6.2.1",
+        "@types/jest": "^30.0.0",
+        "codemirror": "^6.0.2",
+        "idb-keyval": "^6.2.2",
         "markdown-it": "^14.1.0",
         "markdown-it-task-lists": "^2.1.1",
-        "snabbdom": "^3.6.2",
+        "snabbdom": "^3.6.3",
         "sortablejs": "^1.15.6"
       },
       "devDependencies": {
-        "@eslint/js": "^9.21.0",
-        "@lezer/generator": "^1.7.2",
-        "@types/sortablejs": "^1.15.8",
+        "@eslint/js": "^9.39.1",
+        "@lezer/generator": "^1.8.0",
+        "@types/markdown-it": "^14.1.2",
+        "@types/sortablejs": "^1.15.9",
         "chokidar-cli": "^3.0",
-        "esbuild": "^0.25.0",
-        "eslint": "^9.21.0",
-        "eslint-plugin-import": "^2.31.0",
-        "jest": "^29.7.0",
-        "jest-environment-jsdom": "^29.7.0",
-        "livereload": "^0.9.3",
+        "esbuild": "^0.27.0",
+        "eslint": "^9.39.1",
+        "eslint-plugin-import": "^2.32.0",
+        "jest": "^30.2.0",
+        "jest-environment-jsdom": "^30.2.0",
+        "livereload": "^0.10.3",
         "npm-run-all": "^4.1.5",
-        "sass": "^1.85.0",
-        "ts-jest": "^29.2.6",
+        "sass": "^1.94.2",
+        "ts-jest": "^29.4.5",
         "ts-node": "^10.9.2",
-        "typescript": "5.7.*"
+        "typescript": "5.9.*"
       }
     },
     "node_modules/@ampproject/remapping": {
@@ -61,24 +62,45 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@asamuzakjp/css-color": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+      "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/css-calc": "^2.1.3",
+        "@csstools/css-color-parser": "^3.0.9",
+        "@csstools/css-parser-algorithms": "^3.0.4",
+        "@csstools/css-tokenizer": "^3.0.3",
+        "lru-cache": "^10.4.3"
+      }
+    },
+    "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/@babel/code-frame": {
-      "version": "7.26.2",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
-      "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+      "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
       "license": "MIT",
       "dependencies": {
-        "@babel/helper-validator-identifier": "^7.25.9",
+        "@babel/helper-validator-identifier": "^7.27.1",
         "js-tokens": "^4.0.0",
-        "picocolors": "^1.0.0"
+        "picocolors": "^1.1.1"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/compat-data": {
-      "version": "7.26.8",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz",
-      "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==",
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
+      "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -86,22 +108,22 @@
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.26.9",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz",
-      "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==",
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
+      "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@ampproject/remapping": "^2.2.0",
-        "@babel/code-frame": "^7.26.2",
-        "@babel/generator": "^7.26.9",
-        "@babel/helper-compilation-targets": "^7.26.5",
-        "@babel/helper-module-transforms": "^7.26.0",
-        "@babel/helpers": "^7.26.9",
-        "@babel/parser": "^7.26.9",
-        "@babel/template": "^7.26.9",
-        "@babel/traverse": "^7.26.9",
-        "@babel/types": "^7.26.9",
+        "@babel/code-frame": "^7.27.1",
+        "@babel/generator": "^7.28.0",
+        "@babel/helper-compilation-targets": "^7.27.2",
+        "@babel/helper-module-transforms": "^7.27.3",
+        "@babel/helpers": "^7.27.6",
+        "@babel/parser": "^7.28.0",
+        "@babel/template": "^7.27.2",
+        "@babel/traverse": "^7.28.0",
+        "@babel/types": "^7.28.0",
         "convert-source-map": "^2.0.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
@@ -117,16 +139,16 @@
       }
     },
     "node_modules/@babel/generator": {
-      "version": "7.26.9",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz",
-      "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==",
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
+      "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/parser": "^7.26.9",
-        "@babel/types": "^7.26.9",
-        "@jridgewell/gen-mapping": "^0.3.5",
-        "@jridgewell/trace-mapping": "^0.3.25",
+        "@babel/parser": "^7.28.0",
+        "@babel/types": "^7.28.0",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
         "jsesc": "^3.0.2"
       },
       "engines": {
@@ -134,14 +156,14 @@
       }
     },
     "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.26.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz",
-      "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==",
+      "version": "7.27.2",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
+      "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/compat-data": "^7.26.5",
-        "@babel/helper-validator-option": "^7.25.9",
+        "@babel/compat-data": "^7.27.2",
+        "@babel/helper-validator-option": "^7.27.1",
         "browserslist": "^4.24.0",
         "lru-cache": "^5.1.1",
         "semver": "^6.3.1"
@@ -150,30 +172,40 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@babel/helper-globals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/helper-module-imports": {
-      "version": "7.25.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz",
-      "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==",
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+      "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/traverse": "^7.25.9",
-        "@babel/types": "^7.25.9"
+        "@babel/traverse": "^7.27.1",
+        "@babel/types": "^7.27.1"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-module-transforms": {
-      "version": "7.26.0",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz",
-      "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==",
+      "version": "7.27.3",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
+      "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/helper-module-imports": "^7.25.9",
-        "@babel/helper-validator-identifier": "^7.25.9",
-        "@babel/traverse": "^7.25.9"
+        "@babel/helper-module-imports": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.27.1",
+        "@babel/traverse": "^7.27.3"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -183,9 +215,9 @@
       }
     },
     "node_modules/@babel/helper-plugin-utils": {
-      "version": "7.26.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz",
-      "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==",
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+      "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -193,9 +225,9 @@
       }
     },
     "node_modules/@babel/helper-string-parser": {
-      "version": "7.25.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
-      "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -203,18 +235,18 @@
       }
     },
     "node_modules/@babel/helper-validator-identifier": {
-      "version": "7.25.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
-      "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+      "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
       "license": "MIT",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-validator-option": {
-      "version": "7.25.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz",
-      "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==",
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -222,27 +254,27 @@
       }
     },
     "node_modules/@babel/helpers": {
-      "version": "7.26.9",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
-      "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
+      "version": "7.27.6",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
+      "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/template": "^7.26.9",
-        "@babel/types": "^7.26.9"
+        "@babel/template": "^7.27.2",
+        "@babel/types": "^7.27.6"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.26.9",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz",
-      "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
+      "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/types": "^7.26.9"
+        "@babel/types": "^7.28.0"
       },
       "bin": {
         "parser": "bin/babel-parser.js"
@@ -307,13 +339,13 @@
       }
     },
     "node_modules/@babel/plugin-syntax-import-attributes": {
-      "version": "7.26.0",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz",
-      "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==",
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz",
+      "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/helper-plugin-utils": "^7.25.9"
+        "@babel/helper-plugin-utils": "^7.27.1"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -349,13 +381,13 @@
       }
     },
     "node_modules/@babel/plugin-syntax-jsx": {
-      "version": "7.25.9",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz",
-      "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==",
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
+      "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/helper-plugin-utils": "^7.25.9"
+        "@babel/helper-plugin-utils": "^7.27.1"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -475,13 +507,13 @@
       }
     },
     "node_modules/@babel/plugin-syntax-typescript": {
-      "version": "7.25.9",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz",
-      "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==",
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
+      "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/helper-plugin-utils": "^7.25.9"
+        "@babel/helper-plugin-utils": "^7.27.1"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -491,58 +523,48 @@
       }
     },
     "node_modules/@babel/template": {
-      "version": "7.26.9",
-      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
-      "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
+      "version": "7.27.2",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+      "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/code-frame": "^7.26.2",
-        "@babel/parser": "^7.26.9",
-        "@babel/types": "^7.26.9"
+        "@babel/code-frame": "^7.27.1",
+        "@babel/parser": "^7.27.2",
+        "@babel/types": "^7.27.1"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.26.9",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz",
-      "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==",
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
+      "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/code-frame": "^7.26.2",
-        "@babel/generator": "^7.26.9",
-        "@babel/parser": "^7.26.9",
-        "@babel/template": "^7.26.9",
-        "@babel/types": "^7.26.9",
-        "debug": "^4.3.1",
-        "globals": "^11.1.0"
+        "@babel/code-frame": "^7.27.1",
+        "@babel/generator": "^7.28.0",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.28.0",
+        "@babel/template": "^7.27.2",
+        "@babel/types": "^7.28.0",
+        "debug": "^4.3.1"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
-    "node_modules/@babel/traverse/node_modules/globals": {
-      "version": "11.12.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
-      "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/@babel/types": {
-      "version": "7.26.9",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
-      "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
+      "version": "7.28.2",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
+      "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/helper-string-parser": "^7.25.9",
-        "@babel/helper-validator-identifier": "^7.25.9"
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.27.1"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -568,9 +590,9 @@
       }
     },
     "node_modules/@codemirror/commands": {
-      "version": "6.8.0",
-      "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.0.tgz",
-      "integrity": "sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==",
+      "version": "6.10.0",
+      "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
+      "integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/language": "^6.0.0",
@@ -593,9 +615,9 @@
       }
     },
     "node_modules/@codemirror/lang-html": {
-      "version": "6.4.9",
-      "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
-      "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
+      "version": "6.4.11",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
+      "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/autocomplete": "^6.0.0",
@@ -606,13 +628,13 @@
         "@codemirror/view": "^6.17.0",
         "@lezer/common": "^1.0.0",
         "@lezer/css": "^1.1.0",
-        "@lezer/html": "^1.3.0"
+        "@lezer/html": "^1.3.12"
       }
     },
     "node_modules/@codemirror/lang-javascript": {
-      "version": "6.2.3",
-      "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.3.tgz",
-      "integrity": "sha512-8PR3vIWg7pSu7ur8A07pGiYHgy3hHj+mRYRCSG8q+mPIrl0F02rgpGv+DsQTHRTc30rydOsf5PZ7yjKFg2Ackw==",
+      "version": "6.2.4",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
+      "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/autocomplete": "^6.0.0",
@@ -625,9 +647,9 @@
       }
     },
     "node_modules/@codemirror/lang-json": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz",
-      "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==",
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
+      "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/language": "^6.0.0",
@@ -635,9 +657,9 @@
       }
     },
     "node_modules/@codemirror/lang-markdown": {
-      "version": "6.3.2",
-      "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.2.tgz",
-      "integrity": "sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA==",
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
+      "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/autocomplete": "^6.7.1",
@@ -650,9 +672,9 @@
       }
     },
     "node_modules/@codemirror/lang-php": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.1.tgz",
-      "integrity": "sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==",
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
+      "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/lang-html": "^6.0.0",
@@ -677,9 +699,9 @@
       }
     },
     "node_modules/@codemirror/language": {
-      "version": "6.10.8",
-      "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.8.tgz",
-      "integrity": "sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==",
+      "version": "6.11.3",
+      "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
+      "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/state": "^6.0.0",
@@ -691,18 +713,18 @@
       }
     },
     "node_modules/@codemirror/legacy-modes": {
-      "version": "6.4.3",
-      "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.3.tgz",
-      "integrity": "sha512-s1g+q4bil8Cs4O+ueIiKIhz9MQOGcRyxJglma8BYIWc/oEJLo13S3LYSWKaqhKwXGgt1GgZ66hCploHZD9Sstw==",
+      "version": "6.5.2",
+      "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
+      "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/language": "^6.0.0"
       }
     },
     "node_modules/@codemirror/lint": {
-      "version": "6.8.4",
-      "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.4.tgz",
-      "integrity": "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==",
+      "version": "6.8.5",
+      "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
+      "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/state": "^6.0.0",
@@ -711,9 +733,9 @@
       }
     },
     "node_modules/@codemirror/search": {
-      "version": "6.5.9",
-      "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.9.tgz",
-      "integrity": "sha512-7DdQ9aaZMMxuWB1u6IIFWWuK9NocVZwvo4nG8QjJTS6oZGvteoLSiXw3EbVZVlO08Ri2ltO89JVInMpfcJxhtg==",
+      "version": "6.5.11",
+      "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
+      "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/state": "^6.0.0",
@@ -731,9 +753,9 @@
       }
     },
     "node_modules/@codemirror/theme-one-dark": {
-      "version": "6.1.2",
-      "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz",
-      "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==",
+      "version": "6.1.3",
+      "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+      "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/language": "^6.0.0",
@@ -743,12 +765,13 @@
       }
     },
     "node_modules/@codemirror/view": {
-      "version": "6.36.3",
-      "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.3.tgz",
-      "integrity": "sha512-N2bilM47QWC8Hnx0rMdDxO2x2ImJ1FvZWXubwKgjeoOrWwEiFrtpA7SFHcuZ+o2Ze2VzbkgbzWVj4+V18LVkeg==",
+      "version": "6.38.8",
+      "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
+      "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/state": "^6.5.0",
+        "crelt": "^1.0.6",
         "style-mod": "^4.1.0",
         "w3c-keyname": "^2.2.4"
       }
@@ -777,10 +800,159 @@
         "@jridgewell/sourcemap-codec": "^1.4.10"
       }
     },
+    "node_modules/@csstools/color-helpers": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+      "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@csstools/css-calc": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+      "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-color-parser": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+      "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/color-helpers": "^5.1.0",
+        "@csstools/css-calc": "^2.1.4"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-parser-algorithms": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+      "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-tokenizer": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+      "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@emnapi/core": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
+      "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.1.0",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
+      "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/wasi-threads": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
+      "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
     "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
-      "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
+      "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==",
       "cpu": [
         "ppc64"
       ],
@@ -795,9 +967,9 @@
       }
     },
     "node_modules/@esbuild/android-arm": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
-      "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz",
+      "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==",
       "cpu": [
         "arm"
       ],
@@ -812,9 +984,9 @@
       }
     },
     "node_modules/@esbuild/android-arm64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
-      "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz",
+      "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==",
       "cpu": [
         "arm64"
       ],
@@ -829,9 +1001,9 @@
       }
     },
     "node_modules/@esbuild/android-x64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
-      "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz",
+      "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==",
       "cpu": [
         "x64"
       ],
@@ -846,9 +1018,9 @@
       }
     },
     "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
-      "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz",
+      "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==",
       "cpu": [
         "arm64"
       ],
@@ -863,9 +1035,9 @@
       }
     },
     "node_modules/@esbuild/darwin-x64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
-      "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz",
+      "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==",
       "cpu": [
         "x64"
       ],
@@ -880,9 +1052,9 @@
       }
     },
     "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
-      "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz",
+      "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==",
       "cpu": [
         "arm64"
       ],
@@ -897,9 +1069,9 @@
       }
     },
     "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
-      "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz",
+      "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==",
       "cpu": [
         "x64"
       ],
@@ -914,9 +1086,9 @@
       }
     },
     "node_modules/@esbuild/linux-arm": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
-      "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz",
+      "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==",
       "cpu": [
         "arm"
       ],
@@ -931,9 +1103,9 @@
       }
     },
     "node_modules/@esbuild/linux-arm64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
-      "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz",
+      "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==",
       "cpu": [
         "arm64"
       ],
@@ -948,9 +1120,9 @@
       }
     },
     "node_modules/@esbuild/linux-ia32": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
-      "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz",
+      "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==",
       "cpu": [
         "ia32"
       ],
@@ -965,9 +1137,9 @@
       }
     },
     "node_modules/@esbuild/linux-loong64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
-      "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz",
+      "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==",
       "cpu": [
         "loong64"
       ],
@@ -982,9 +1154,9 @@
       }
     },
     "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
-      "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz",
+      "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==",
       "cpu": [
         "mips64el"
       ],
@@ -999,9 +1171,9 @@
       }
     },
     "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
-      "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz",
+      "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==",
       "cpu": [
         "ppc64"
       ],
@@ -1016,9 +1188,9 @@
       }
     },
     "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
-      "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz",
+      "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==",
       "cpu": [
         "riscv64"
       ],
@@ -1033,9 +1205,9 @@
       }
     },
     "node_modules/@esbuild/linux-s390x": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
-      "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz",
+      "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==",
       "cpu": [
         "s390x"
       ],
@@ -1050,9 +1222,9 @@
       }
     },
     "node_modules/@esbuild/linux-x64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
-      "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz",
+      "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==",
       "cpu": [
         "x64"
       ],
@@ -1067,9 +1239,9 @@
       }
     },
     "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
-      "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz",
+      "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==",
       "cpu": [
         "arm64"
       ],
@@ -1084,9 +1256,9 @@
       }
     },
     "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
-      "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz",
+      "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==",
       "cpu": [
         "x64"
       ],
@@ -1101,9 +1273,9 @@
       }
     },
     "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
-      "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz",
+      "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==",
       "cpu": [
         "arm64"
       ],
@@ -1118,9 +1290,9 @@
       }
     },
     "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
-      "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz",
+      "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==",
       "cpu": [
         "x64"
       ],
@@ -1134,10 +1306,27 @@
         "node": ">=18"
       }
     },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz",
+      "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/sunos-x64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
-      "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz",
+      "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==",
       "cpu": [
         "x64"
       ],
@@ -1152,9 +1341,9 @@
       }
     },
     "node_modules/@esbuild/win32-arm64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
-      "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz",
+      "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==",
       "cpu": [
         "arm64"
       ],
@@ -1169,9 +1358,9 @@
       }
     },
     "node_modules/@esbuild/win32-ia32": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
-      "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz",
+      "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==",
       "cpu": [
         "ia32"
       ],
@@ -1186,9 +1375,9 @@
       }
     },
     "node_modules/@esbuild/win32-x64": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
-      "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz",
+      "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==",
       "cpu": [
         "x64"
       ],
@@ -1203,9 +1392,9 @@
       }
     },
     "node_modules/@eslint-community/eslint-utils": {
-      "version": "4.4.1",
-      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
-      "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==",
+      "version": "4.9.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
+      "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -1245,13 +1434,13 @@
       }
     },
     "node_modules/@eslint/config-array": {
-      "version": "0.19.2",
-      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz",
-      "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==",
+      "version": "0.21.1",
+      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+      "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@eslint/object-schema": "^2.1.6",
+        "@eslint/object-schema": "^2.1.7",
         "debug": "^4.3.1",
         "minimatch": "^3.1.2"
       },
@@ -1259,10 +1448,23 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@eslint/config-helpers": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+      "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.17.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
     "node_modules/@eslint/core": {
-      "version": "0.12.0",
-      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
-      "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+      "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -1273,9 +1475,9 @@
       }
     },
     "node_modules/@eslint/eslintrc": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz",
-      "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==",
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+      "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -1297,19 +1499,22 @@
       }
     },
     "node_modules/@eslint/js": {
-      "version": "9.21.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz",
-      "integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==",
+      "version": "9.39.1",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
+      "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
       "dev": true,
       "license": "MIT",
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
       }
     },
     "node_modules/@eslint/object-schema": {
-      "version": "2.1.6",
-      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
-      "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+      "version": "2.1.7",
+      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+      "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
       "dev": true,
       "license": "Apache-2.0",
       "engines": {
@@ -1317,13 +1522,13 @@
       }
     },
     "node_modules/@eslint/plugin-kit": {
-      "version": "0.2.7",
-      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz",
-      "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==",
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+      "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@eslint/core": "^0.12.0",
+        "@eslint/core": "^0.17.0",
         "levn": "^0.4.1"
       },
       "engines": {
@@ -1383,9 +1588,9 @@
       }
     },
     "node_modules/@humanwhocodes/retry": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz",
-      "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==",
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
       "dev": true,
       "license": "Apache-2.0",
       "engines": {
@@ -1396,6 +1601,80 @@
         "url": "https://github.com/sponsors/nzakas"
       }
     },
+    "node_modules/@isaacs/cliui": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+      "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^5.1.2",
+        "string-width-cjs": "npm:string-width@^4.2.0",
+        "strip-ansi": "^7.0.1",
+        "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+        "wrap-ansi": "^8.1.0",
+        "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
+      "version": "6.2.3",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+      "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@isaacs/cliui/node_modules/string-width": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+      "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eastasianwidth": "^0.2.0",
+        "emoji-regex": "^9.2.2",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+      "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^6.1.0",
+        "string-width": "^5.0.1",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
     "node_modules/@istanbuljs/load-nyc-config": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -1438,9 +1717,9 @@
       }
     },
     "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
-      "version": "3.14.1",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
-      "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+      "version": "3.14.2",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+      "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -1514,61 +1793,61 @@
       }
     },
     "node_modules/@jest/console": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz",
-      "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz",
+      "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/types": "^29.6.3",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "chalk": "^4.0.0",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0",
+        "chalk": "^4.1.2",
+        "jest-message-util": "30.2.0",
+        "jest-util": "30.2.0",
         "slash": "^3.0.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/core": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
-      "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz",
+      "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/console": "^29.7.0",
-        "@jest/reporters": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
+        "@jest/console": "30.2.0",
+        "@jest/pattern": "30.0.1",
+        "@jest/reporters": "30.2.0",
+        "@jest/test-result": "30.2.0",
+        "@jest/transform": "30.2.0",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "ansi-escapes": "^4.2.1",
-        "chalk": "^4.0.0",
-        "ci-info": "^3.2.0",
-        "exit": "^0.1.2",
-        "graceful-fs": "^4.2.9",
-        "jest-changed-files": "^29.7.0",
-        "jest-config": "^29.7.0",
-        "jest-haste-map": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-regex-util": "^29.6.3",
-        "jest-resolve": "^29.7.0",
-        "jest-resolve-dependencies": "^29.7.0",
-        "jest-runner": "^29.7.0",
-        "jest-runtime": "^29.7.0",
-        "jest-snapshot": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "jest-watcher": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "pretty-format": "^29.7.0",
-        "slash": "^3.0.0",
-        "strip-ansi": "^6.0.0"
+        "ansi-escapes": "^4.3.2",
+        "chalk": "^4.1.2",
+        "ci-info": "^4.2.0",
+        "exit-x": "^0.2.2",
+        "graceful-fs": "^4.2.11",
+        "jest-changed-files": "30.2.0",
+        "jest-config": "30.2.0",
+        "jest-haste-map": "30.2.0",
+        "jest-message-util": "30.2.0",
+        "jest-regex-util": "30.0.1",
+        "jest-resolve": "30.2.0",
+        "jest-resolve-dependencies": "30.2.0",
+        "jest-runner": "30.2.0",
+        "jest-runtime": "30.2.0",
+        "jest-snapshot": "30.2.0",
+        "jest-util": "30.2.0",
+        "jest-validate": "30.2.0",
+        "jest-watcher": "30.2.0",
+        "micromatch": "^4.0.8",
+        "pretty-format": "30.2.0",
+        "slash": "^3.0.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       },
       "peerDependencies": {
         "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
@@ -1579,116 +1858,174 @@
         }
       }
     },
+    "node_modules/@jest/diff-sequences": {
+      "version": "30.0.1",
+      "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz",
+      "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==",
+      "license": "MIT",
+      "engines": {
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+      }
+    },
     "node_modules/@jest/environment": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
-      "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz",
+      "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jest/fake-timers": "30.2.0",
+        "@jest/types": "30.2.0",
+        "@types/node": "*",
+        "jest-mock": "30.2.0"
+      },
+      "engines": {
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@jest/environment-jsdom-abstract": {
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz",
+      "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/types": "^29.6.3",
+        "@jest/environment": "30.2.0",
+        "@jest/fake-timers": "30.2.0",
+        "@jest/types": "30.2.0",
+        "@types/jsdom": "^21.1.7",
         "@types/node": "*",
-        "jest-mock": "^29.7.0"
+        "jest-mock": "30.2.0",
+        "jest-util": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "canvas": "^3.0.0",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
       }
     },
     "node_modules/@jest/expect": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
-      "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz",
+      "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "expect": "^29.7.0",
-        "jest-snapshot": "^29.7.0"
+        "expect": "30.2.0",
+        "jest-snapshot": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/expect-utils": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
-      "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz",
+      "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==",
       "license": "MIT",
       "dependencies": {
-        "jest-get-type": "^29.6.3"
+        "@jest/get-type": "30.1.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/fake-timers": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
-      "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz",
+      "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@sinonjs/fake-timers": "^10.0.2",
+        "@jest/types": "30.2.0",
+        "@sinonjs/fake-timers": "^13.0.0",
         "@types/node": "*",
-        "jest-message-util": "^29.7.0",
-        "jest-mock": "^29.7.0",
-        "jest-util": "^29.7.0"
+        "jest-message-util": "30.2.0",
+        "jest-mock": "30.2.0",
+        "jest-util": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@jest/get-type": {
+      "version": "30.1.0",
+      "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
+      "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
+      "license": "MIT",
+      "engines": {
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/globals": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
-      "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz",
+      "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/expect": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "jest-mock": "^29.7.0"
+        "@jest/environment": "30.2.0",
+        "@jest/expect": "30.2.0",
+        "@jest/types": "30.2.0",
+        "jest-mock": "30.2.0"
+      },
+      "engines": {
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@jest/pattern": {
+      "version": "30.0.1",
+      "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz",
+      "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "jest-regex-util": "30.0.1"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/reporters": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
-      "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz",
+      "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@bcoe/v8-coverage": "^0.2.3",
-        "@jest/console": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@jridgewell/trace-mapping": "^0.3.18",
+        "@jest/console": "30.2.0",
+        "@jest/test-result": "30.2.0",
+        "@jest/transform": "30.2.0",
+        "@jest/types": "30.2.0",
+        "@jridgewell/trace-mapping": "^0.3.25",
         "@types/node": "*",
-        "chalk": "^4.0.0",
-        "collect-v8-coverage": "^1.0.0",
-        "exit": "^0.1.2",
-        "glob": "^7.1.3",
-        "graceful-fs": "^4.2.9",
+        "chalk": "^4.1.2",
+        "collect-v8-coverage": "^1.0.2",
+        "exit-x": "^0.2.2",
+        "glob": "^10.3.10",
+        "graceful-fs": "^4.2.11",
         "istanbul-lib-coverage": "^3.0.0",
         "istanbul-lib-instrument": "^6.0.0",
         "istanbul-lib-report": "^3.0.0",
-        "istanbul-lib-source-maps": "^4.0.0",
+        "istanbul-lib-source-maps": "^5.0.0",
         "istanbul-reports": "^3.1.3",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-worker": "^29.7.0",
+        "jest-message-util": "30.2.0",
+        "jest-util": "30.2.0",
+        "jest-worker": "30.2.0",
         "slash": "^3.0.0",
-        "string-length": "^4.0.1",
-        "strip-ansi": "^6.0.0",
+        "string-length": "^4.0.2",
         "v8-to-istanbul": "^9.0.1"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       },
       "peerDependencies": {
         "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
@@ -1700,121 +2037,134 @@
       }
     },
     "node_modules/@jest/schemas": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
-      "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+      "version": "30.0.5",
+      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz",
+      "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==",
       "license": "MIT",
       "dependencies": {
-        "@sinclair/typebox": "^0.27.8"
+        "@sinclair/typebox": "^0.34.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+      }
+    },
+    "node_modules/@jest/snapshot-utils": {
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz",
+      "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jest/types": "30.2.0",
+        "chalk": "^4.1.2",
+        "graceful-fs": "^4.2.11",
+        "natural-compare": "^1.4.0"
+      },
+      "engines": {
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/source-map": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
-      "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
+      "version": "30.0.1",
+      "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz",
+      "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jridgewell/trace-mapping": "^0.3.18",
-        "callsites": "^3.0.0",
-        "graceful-fs": "^4.2.9"
+        "@jridgewell/trace-mapping": "^0.3.25",
+        "callsites": "^3.1.0",
+        "graceful-fs": "^4.2.11"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/test-result": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
-      "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz",
+      "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/console": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/istanbul-lib-coverage": "^2.0.0",
-        "collect-v8-coverage": "^1.0.0"
+        "@jest/console": "30.2.0",
+        "@jest/types": "30.2.0",
+        "@types/istanbul-lib-coverage": "^2.0.6",
+        "collect-v8-coverage": "^1.0.2"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/test-sequencer": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
-      "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz",
+      "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/test-result": "^29.7.0",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
+        "@jest/test-result": "30.2.0",
+        "graceful-fs": "^4.2.11",
+        "jest-haste-map": "30.2.0",
         "slash": "^3.0.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/transform": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
-      "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz",
+      "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@jest/types": "^29.6.3",
-        "@jridgewell/trace-mapping": "^0.3.18",
-        "babel-plugin-istanbul": "^6.1.1",
-        "chalk": "^4.0.0",
+        "@babel/core": "^7.27.4",
+        "@jest/types": "30.2.0",
+        "@jridgewell/trace-mapping": "^0.3.25",
+        "babel-plugin-istanbul": "^7.0.1",
+        "chalk": "^4.1.2",
         "convert-source-map": "^2.0.0",
         "fast-json-stable-stringify": "^2.1.0",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "jest-regex-util": "^29.6.3",
-        "jest-util": "^29.7.0",
-        "micromatch": "^4.0.4",
-        "pirates": "^4.0.4",
+        "graceful-fs": "^4.2.11",
+        "jest-haste-map": "30.2.0",
+        "jest-regex-util": "30.0.1",
+        "jest-util": "30.2.0",
+        "micromatch": "^4.0.8",
+        "pirates": "^4.0.7",
         "slash": "^3.0.0",
-        "write-file-atomic": "^4.0.2"
+        "write-file-atomic": "^5.0.1"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jest/types": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
-      "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz",
+      "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==",
       "license": "MIT",
       "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "@types/istanbul-lib-coverage": "^2.0.0",
-        "@types/istanbul-reports": "^3.0.0",
+        "@jest/pattern": "30.0.1",
+        "@jest/schemas": "30.0.5",
+        "@types/istanbul-lib-coverage": "^2.0.6",
+        "@types/istanbul-reports": "^3.0.4",
         "@types/node": "*",
-        "@types/yargs": "^17.0.8",
-        "chalk": "^4.0.0"
+        "@types/yargs": "^17.0.33",
+        "chalk": "^4.1.2"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/@jridgewell/gen-mapping": {
-      "version": "0.3.8",
-      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
-      "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
+      "version": "0.3.12",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
+      "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jridgewell/set-array": "^1.2.1",
-        "@jridgewell/sourcemap-codec": "^1.4.10",
+        "@jridgewell/sourcemap-codec": "^1.5.0",
         "@jridgewell/trace-mapping": "^0.3.24"
-      },
-      "engines": {
-        "node": ">=6.0.0"
       }
     },
     "node_modules/@jridgewell/resolve-uri": {
@@ -1827,26 +2177,17 @@
         "node": ">=6.0.0"
       }
     },
-    "node_modules/@jridgewell/set-array": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
-      "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
+      "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
       "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.0.0"
-      }
-    },
-    "node_modules/@jridgewell/sourcemap-codec": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
-      "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
       "license": "MIT"
     },
     "node_modules/@jridgewell/trace-mapping": {
-      "version": "0.3.25",
-      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
-      "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+      "version": "0.3.29",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
+      "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -1855,26 +2196,26 @@
       }
     },
     "node_modules/@lezer/common": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
-      "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
+      "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
       "license": "MIT"
     },
     "node_modules/@lezer/css": {
-      "version": "1.1.10",
-      "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.10.tgz",
-      "integrity": "sha512-V5/89eDapjeAkWPBpWEfQjZ1Hag3aYUUJOL8213X0dFRuXJ4BXa5NKl9USzOnaLod4AOpmVCkduir2oKwZYZtg==",
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
+      "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
       "license": "MIT",
       "dependencies": {
         "@lezer/common": "^1.2.0",
         "@lezer/highlight": "^1.0.0",
-        "@lezer/lr": "^1.0.0"
+        "@lezer/lr": "^1.3.0"
       }
     },
     "node_modules/@lezer/generator": {
-      "version": "1.7.2",
-      "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.2.tgz",
-      "integrity": "sha512-CwgULPOPPmH54tv4gki18bElLCdJ1+FBC+nGVSVD08vFWDsMjS7KEjNTph9JOypDnet90ujN3LzQiW3CyVODNQ==",
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.8.0.tgz",
+      "integrity": "sha512-/SF4EDWowPqV1jOgoGSGTIFsE7Ezdr7ZYxyihl5eMKVO5tlnpIhFcDavgm1hHY5GEonoOAEnJ0CU0x+tvuAuUg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -1886,18 +2227,18 @@
       }
     },
     "node_modules/@lezer/highlight": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
-      "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+      "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
       "license": "MIT",
       "dependencies": {
-        "@lezer/common": "^1.0.0"
+        "@lezer/common": "^1.3.0"
       }
     },
     "node_modules/@lezer/html": {
-      "version": "1.3.10",
-      "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
-      "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
+      "version": "1.3.12",
+      "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz",
+      "integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==",
       "license": "MIT",
       "dependencies": {
         "@lezer/common": "^1.2.0",
@@ -1906,9 +2247,9 @@
       }
     },
     "node_modules/@lezer/javascript": {
-      "version": "1.4.21",
-      "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.21.tgz",
-      "integrity": "sha512-lL+1fcuxWYPURMM/oFZLEDm0XuLN128QPV+VuGtKpeaOGdcl9F2LYC3nh1S9LkPqx9M0mndZFdXCipNAZpzIkQ==",
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz",
+      "integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==",
       "license": "MIT",
       "dependencies": {
         "@lezer/common": "^1.2.0",
@@ -1937,20 +2278,19 @@
       }
     },
     "node_modules/@lezer/markdown": {
-      "version": "1.4.1",
-      "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.1.tgz",
-      "integrity": "sha512-Za5okfyWoNaX6sSZ2dm94XegaFXbkQ9UjKJ8hAoZX88XDpbu6DoR63IuSl+dqj1VkVQBQGsdr0JnTcMsogQDdw==",
+      "version": "1.4.3",
+      "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz",
+      "integrity": "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg==",
       "license": "MIT",
       "dependencies": {
         "@lezer/common": "^1.0.0",
-        "@lezer/highlight": "^1.0.0",
-        "@marijn/buildtool": "^0.1.6"
+        "@lezer/highlight": "^1.0.0"
       }
     },
     "node_modules/@lezer/php": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.2.tgz",
-      "integrity": "sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.4.tgz",
+      "integrity": "sha512-D2dJ0t8Z28/G1guztRczMFvPDUqzeMLSQbdWQmaiHV7urc8NlEOnjYk9UrZ531OcLiRxD4Ihcbv7AsDpNKDRaQ==",
       "license": "MIT",
       "dependencies": {
         "@lezer/common": "^1.2.0",
@@ -1969,26 +2309,25 @@
         "@lezer/lr": "^1.0.0"
       }
     },
-    "node_modules/@marijn/buildtool": {
-      "version": "0.1.6",
-      "resolved": "https://registry.npmjs.org/@marijn/buildtool/-/buildtool-0.1.6.tgz",
-      "integrity": "sha512-rcA2wljsM24MFAwx2U5vSBrt7IdIaPh4WPRfJPS8PuCUlbuQ8Pmky4c/ec00v3YFu90rZSbkVLnPuCeb/mUEng==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/mocha": "^9.1.1",
-        "acorn": "^8.10.0",
-        "acorn-walk": "^8.2.0",
-        "rollup": "^3.28.0",
-        "rollup-plugin-dts": "^5.3.1",
-        "typescript": "^5.1.6"
-      }
-    },
     "node_modules/@marijn/find-cluster-break": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
       "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
       "license": "MIT"
     },
+    "node_modules/@napi-rs/wasm-runtime": {
+      "version": "0.2.12",
+      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
+      "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "^1.4.3",
+        "@emnapi/runtime": "^1.4.3",
+        "@tybys/wasm-util": "^0.10.0"
+      }
+    },
     "node_modules/@parcel/watcher": {
       "version": "2.5.1",
       "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -2299,6 +2638,30 @@
         "url": "https://opencollective.com/parcel"
       }
     },
+    "node_modules/@pkgjs/parseargs": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+      "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@pkgr/core": {
+      "version": "0.2.9",
+      "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
+      "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/pkgr"
+      }
+    },
     "node_modules/@rtsao/scc": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2307,9 +2670,9 @@
       "license": "MIT"
     },
     "node_modules/@sinclair/typebox": {
-      "version": "0.27.8",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
-      "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
+      "version": "0.34.41",
+      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
+      "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==",
       "license": "MIT"
     },
     "node_modules/@sinonjs/commons": {
@@ -2323,13 +2686,13 @@
       }
     },
     "node_modules/@sinonjs/fake-timers": {
-      "version": "10.3.0",
-      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
-      "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
+      "version": "13.0.5",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz",
+      "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==",
       "dev": true,
       "license": "BSD-3-Clause",
       "dependencies": {
-        "@sinonjs/commons": "^3.0.0"
+        "@sinonjs/commons": "^3.0.1"
       }
     },
     "node_modules/@ssddanbrown/codemirror-lang-smarty": {
@@ -2349,16 +2712,6 @@
         "@lezer/lr": "^1.0.0"
       }
     },
-    "node_modules/@tootallnate/once": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
-      "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 10"
-      }
-    },
     "node_modules/@tsconfig/node10": {
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@@ -2387,6 +2740,17 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@tybys/wasm-util": {
+      "version": "0.10.1",
+      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+      "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
     "node_modules/@types/babel__core": {
       "version": "7.20.5",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2402,9 +2766,9 @@
       }
     },
     "node_modules/@types/babel__generator": {
-      "version": "7.6.8",
-      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
-      "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -2423,32 +2787,22 @@
       }
     },
     "node_modules/@types/babel__traverse": {
-      "version": "7.20.6",
-      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
-      "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/types": "^7.20.7"
+        "@babel/types": "^7.28.2"
       }
     },
     "node_modules/@types/estree": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
-      "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/@types/graceful-fs": {
-      "version": "4.1.9",
-      "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
-      "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*"
-      }
-    },
     "node_modules/@types/istanbul-lib-coverage": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2474,19 +2828,19 @@
       }
     },
     "node_modules/@types/jest": {
-      "version": "29.5.14",
-      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
-      "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
+      "version": "30.0.0",
+      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz",
+      "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==",
       "license": "MIT",
       "dependencies": {
-        "expect": "^29.0.0",
-        "pretty-format": "^29.0.0"
+        "expect": "^30.0.0",
+        "pretty-format": "^30.0.0"
       }
     },
     "node_modules/@types/jsdom": {
-      "version": "20.0.1",
-      "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",
-      "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==",
+      "version": "21.1.7",
+      "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz",
+      "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -2509,25 +2863,44 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/@types/mocha": {
-      "version": "9.1.1",
-      "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz",
-      "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==",
+    "node_modules/@types/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/markdown-it": {
+      "version": "14.1.2",
+      "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+      "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/linkify-it": "^5",
+        "@types/mdurl": "^2"
+      }
+    },
+    "node_modules/@types/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/@types/node": {
-      "version": "22.13.5",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
-      "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
+      "version": "24.1.0",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
+      "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
       "license": "MIT",
       "dependencies": {
-        "undici-types": "~6.20.0"
+        "undici-types": "~7.8.0"
       }
     },
     "node_modules/@types/sortablejs": {
-      "version": "1.15.8",
-      "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz",
-      "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==",
+      "version": "1.15.9",
+      "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
+      "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
       "dev": true,
       "license": "MIT"
     },
@@ -2559,35 +2932,293 @@
       "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
       "license": "MIT"
     },
-    "node_modules/abab": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
-      "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
-      "deprecated": "Use your platform's native atob() and btoa() methods instead",
+    "node_modules/@ungap/structured-clone": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+      "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
       "dev": true,
-      "license": "BSD-3-Clause"
+      "license": "ISC"
     },
-    "node_modules/acorn": {
-      "version": "8.14.0",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
-      "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
+    "node_modules/@unrs/resolver-binding-android-arm-eabi": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
+      "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
       "license": "MIT",
-      "bin": {
-        "acorn": "bin/acorn"
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-android-arm64": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
+      "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-darwin-arm64": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
+      "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-darwin-x64": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
+      "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-freebsd-x64": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
+      "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
+      "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
+      "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
+      "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
+      "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
+      "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
+      "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
+      "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
+      "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
+      "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-linux-x64-musl": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
+      "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-wasm32-wasi": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
+      "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@napi-rs/wasm-runtime": "^0.2.11"
       },
       "engines": {
-        "node": ">=0.4.0"
+        "node": ">=14.0.0"
       }
     },
-    "node_modules/acorn-globals": {
-      "version": "7.0.1",
-      "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
-      "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
+    "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
+      "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
+      "cpu": [
+        "arm64"
+      ],
       "dev": true,
       "license": "MIT",
-      "dependencies": {
-        "acorn": "^8.1.0",
-        "acorn-walk": "^8.0.2"
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
+      "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
+      "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/acorn": {
+      "version": "8.15.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
       }
     },
     "node_modules/acorn-jsx": {
@@ -2604,6 +3235,7 @@
       "version": "8.3.4",
       "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
       "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "acorn": "^8.11.0"
@@ -2613,16 +3245,13 @@
       }
     },
     "node_modules/agent-base": {
-      "version": "6.0.2",
-      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
-      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
       "dev": true,
       "license": "MIT",
-      "dependencies": {
-        "debug": "4"
-      },
       "engines": {
-        "node": ">= 6.0.0"
+        "node": ">= 14"
       }
     },
     "node_modules/ajv": {
@@ -2659,13 +3288,16 @@
       }
     },
     "node_modules/ansi-regex": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
-      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+      "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
       "dev": true,
       "license": "MIT",
       "engines": {
-        "node": ">=8"
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
       }
     },
     "node_modules/ansi-styles": {
@@ -2728,18 +3360,20 @@
       }
     },
     "node_modules/array-includes": {
-      "version": "3.1.8",
-      "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
-      "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
+      "version": "3.1.9",
+      "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+      "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "call-bind": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
         "define-properties": "^1.2.1",
-        "es-abstract": "^1.23.2",
-        "es-object-atoms": "^1.0.0",
-        "get-intrinsic": "^1.2.4",
-        "is-string": "^1.0.7"
+        "es-abstract": "^1.24.0",
+        "es-object-atoms": "^1.1.1",
+        "get-intrinsic": "^1.3.0",
+        "is-string": "^1.1.1",
+        "math-intrinsics": "^1.1.0"
       },
       "engines": {
         "node": ">= 0.4"
@@ -2749,18 +3383,19 @@
       }
     },
     "node_modules/array.prototype.findlastindex": {
-      "version": "1.2.5",
-      "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz",
-      "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==",
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
+      "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "call-bind": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
         "define-properties": "^1.2.1",
-        "es-abstract": "^1.23.2",
+        "es-abstract": "^1.23.9",
         "es-errors": "^1.3.0",
-        "es-object-atoms": "^1.0.0",
-        "es-shim-unscopables": "^1.0.2"
+        "es-object-atoms": "^1.1.1",
+        "es-shim-unscopables": "^1.1.0"
       },
       "engines": {
         "node": ">= 0.4"
@@ -2829,13 +3464,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/async": {
-      "version": "3.2.6",
-      "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
-      "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/async-function": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -2846,13 +3474,6 @@
         "node": ">= 0.4"
       }
     },
-    "node_modules/asynckit": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/available-typed-arrays": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2870,81 +3491,64 @@
       }
     },
     "node_modules/babel-jest": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
-      "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
+      "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/transform": "^29.7.0",
-        "@types/babel__core": "^7.1.14",
-        "babel-plugin-istanbul": "^6.1.1",
-        "babel-preset-jest": "^29.6.3",
-        "chalk": "^4.0.0",
-        "graceful-fs": "^4.2.9",
+        "@jest/transform": "30.2.0",
+        "@types/babel__core": "^7.20.5",
+        "babel-plugin-istanbul": "^7.0.1",
+        "babel-preset-jest": "30.2.0",
+        "chalk": "^4.1.2",
+        "graceful-fs": "^4.2.11",
         "slash": "^3.0.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       },
       "peerDependencies": {
-        "@babel/core": "^7.8.0"
+        "@babel/core": "^7.11.0 || ^8.0.0-0"
       }
     },
     "node_modules/babel-plugin-istanbul": {
-      "version": "6.1.1",
-      "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
-      "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz",
+      "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==",
       "dev": true,
       "license": "BSD-3-Clause",
+      "workspaces": [
+        "test/babel-8"
+      ],
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.0.0",
         "@istanbuljs/load-nyc-config": "^1.0.0",
-        "@istanbuljs/schema": "^0.1.2",
-        "istanbul-lib-instrument": "^5.0.4",
+        "@istanbuljs/schema": "^0.1.3",
+        "istanbul-lib-instrument": "^6.0.2",
         "test-exclude": "^6.0.0"
       },
       "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": {
-      "version": "5.2.1",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
-      "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
-      "dev": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@babel/core": "^7.12.3",
-        "@babel/parser": "^7.14.7",
-        "@istanbuljs/schema": "^0.1.2",
-        "istanbul-lib-coverage": "^3.2.0",
-        "semver": "^6.3.0"
-      },
-      "engines": {
-        "node": ">=8"
+        "node": ">=12"
       }
     },
     "node_modules/babel-plugin-jest-hoist": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz",
-      "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz",
+      "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@babel/template": "^7.3.3",
-        "@babel/types": "^7.3.3",
-        "@types/babel__core": "^7.1.14",
-        "@types/babel__traverse": "^7.0.6"
+        "@types/babel__core": "^7.20.5"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/babel-preset-current-node-syntax": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz",
-      "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==",
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz",
+      "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -2965,24 +3569,24 @@
         "@babel/plugin-syntax-top-level-await": "^7.14.5"
       },
       "peerDependencies": {
-        "@babel/core": "^7.0.0"
+        "@babel/core": "^7.0.0 || ^8.0.0-0"
       }
     },
     "node_modules/babel-preset-jest": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz",
-      "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz",
+      "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "babel-plugin-jest-hoist": "^29.6.3",
-        "babel-preset-current-node-syntax": "^1.0.0"
+        "babel-plugin-jest-hoist": "30.2.0",
+        "babel-preset-current-node-syntax": "^1.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       },
       "peerDependencies": {
-        "@babel/core": "^7.0.0"
+        "@babel/core": "^7.11.0 || ^8.0.0-beta.1"
       }
     },
     "node_modules/balanced-match": {
@@ -3006,9 +3610,9 @@
       }
     },
     "node_modules/brace-expansion": {
-      "version": "1.1.11",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
-      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3029,9 +3633,9 @@
       }
     },
     "node_modules/browserslist": {
-      "version": "4.24.4",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
-      "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
+      "version": "4.25.1",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
+      "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
       "dev": true,
       "funding": [
         {
@@ -3049,10 +3653,10 @@
       ],
       "license": "MIT",
       "dependencies": {
-        "caniuse-lite": "^1.0.30001688",
-        "electron-to-chromium": "^1.5.73",
+        "caniuse-lite": "^1.0.30001726",
+        "electron-to-chromium": "^1.5.173",
         "node-releases": "^2.0.19",
-        "update-browserslist-db": "^1.1.1"
+        "update-browserslist-db": "^1.1.3"
       },
       "bin": {
         "browserslist": "cli.js"
@@ -3125,14 +3729,14 @@
       }
     },
     "node_modules/call-bound": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
-      "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "call-bind-apply-helpers": "^1.0.1",
-        "get-intrinsic": "^1.2.6"
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
       },
       "engines": {
         "node": ">= 0.4"
@@ -3162,9 +3766,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001700",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
-      "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
+      "version": "1.0.30001727",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
+      "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
       "dev": true,
       "funding": [
         {
@@ -3253,9 +3857,9 @@
       }
     },
     "node_modules/ci-info": {
-      "version": "3.9.0",
-      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
-      "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz",
+      "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==",
       "funding": [
         {
           "type": "github",
@@ -3268,9 +3872,9 @@
       }
     },
     "node_modules/cjs-module-lexer": {
-      "version": "1.4.3",
-      "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
-      "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz",
+      "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==",
       "dev": true,
       "license": "MIT"
     },
@@ -3321,9 +3925,9 @@
       }
     },
     "node_modules/codemirror": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
-      "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+      "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
       "license": "MIT",
       "dependencies": {
         "@codemirror/autocomplete": "^6.0.0",
@@ -3336,9 +3940,9 @@
       }
     },
     "node_modules/collect-v8-coverage": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
-      "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
+      "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==",
       "dev": true,
       "license": "MIT"
     },
@@ -3360,19 +3964,6 @@
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
       "license": "MIT"
     },
-    "node_modules/combined-stream": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
-      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "delayed-stream": "~1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
     "node_modules/concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3380,35 +3971,13 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/convert-source-map": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
-      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/create-jest": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
-      "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/types": "^29.6.3",
-        "chalk": "^4.0.0",
-        "exit": "^0.1.2",
-        "graceful-fs": "^4.2.9",
-        "jest-config": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "prompts": "^2.0.1"
-      },
-      "bin": {
-        "create-jest": "bin/create-jest.js"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/create-require": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -3437,46 +4006,32 @@
         "node": ">= 8"
       }
     },
-    "node_modules/cssom": {
-      "version": "0.5.0",
-      "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
-      "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/cssstyle": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
-      "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+      "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "cssom": "~0.3.6"
+        "@asamuzakjp/css-color": "^3.2.0",
+        "rrweb-cssom": "^0.8.0"
       },
       "engines": {
-        "node": ">=8"
+        "node": ">=18"
       }
     },
-    "node_modules/cssstyle/node_modules/cssom": {
-      "version": "0.3.8",
-      "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
-      "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/data-urls": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
-      "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+      "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "abab": "^2.0.6",
-        "whatwg-mimetype": "^3.0.0",
-        "whatwg-url": "^11.0.0"
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^14.0.0"
       },
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/data-view-buffer": {
@@ -3534,9 +4089,9 @@
       }
     },
     "node_modules/debug": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
-      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+      "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3562,16 +4117,16 @@
       }
     },
     "node_modules/decimal.js": {
-      "version": "10.5.0",
-      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
-      "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
       "dev": true,
       "license": "MIT"
     },
     "node_modules/dedent": {
-      "version": "1.5.3",
-      "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz",
-      "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==",
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz",
+      "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==",
       "dev": true,
       "license": "MIT",
       "peerDependencies": {
@@ -3636,16 +4191,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/delayed-stream": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
     "node_modules/detect-libc": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -3680,15 +4225,6 @@
         "node": ">=0.3.1"
       }
     },
-    "node_modules/diff-sequences": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
-      "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
-      "license": "MIT",
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
     "node_modules/doctrine": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3702,20 +4238,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/domexception": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
-      "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
-      "deprecated": "Use your platform's native DOMException instead",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "webidl-conversions": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3731,26 +4253,17 @@
         "node": ">= 0.4"
       }
     },
-    "node_modules/ejs": {
-      "version": "3.1.10",
-      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
-      "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+    "node_modules/eastasianwidth": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+      "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
       "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "jake": "^10.8.5"
-      },
-      "bin": {
-        "ejs": "bin/cli.js"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
+      "license": "MIT"
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.5.103",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
-      "integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
+      "version": "1.5.190",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz",
+      "integrity": "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==",
       "dev": true,
       "license": "ISC"
     },
@@ -3797,9 +4310,9 @@
       }
     },
     "node_modules/es-abstract": {
-      "version": "1.23.9",
-      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
-      "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==",
+      "version": "1.24.0",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
+      "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3807,18 +4320,18 @@
         "arraybuffer.prototype.slice": "^1.0.4",
         "available-typed-arrays": "^1.0.7",
         "call-bind": "^1.0.8",
-        "call-bound": "^1.0.3",
+        "call-bound": "^1.0.4",
         "data-view-buffer": "^1.0.2",
         "data-view-byte-length": "^1.0.2",
         "data-view-byte-offset": "^1.0.1",
         "es-define-property": "^1.0.1",
         "es-errors": "^1.3.0",
-        "es-object-atoms": "^1.0.0",
+        "es-object-atoms": "^1.1.1",
         "es-set-tostringtag": "^2.1.0",
         "es-to-primitive": "^1.3.0",
         "function.prototype.name": "^1.1.8",
-        "get-intrinsic": "^1.2.7",
-        "get-proto": "^1.0.0",
+        "get-intrinsic": "^1.3.0",
+        "get-proto": "^1.0.1",
         "get-symbol-description": "^1.1.0",
         "globalthis": "^1.0.4",
         "gopd": "^1.2.0",
@@ -3830,21 +4343,24 @@
         "is-array-buffer": "^3.0.5",
         "is-callable": "^1.2.7",
         "is-data-view": "^1.0.2",
+        "is-negative-zero": "^2.0.3",
         "is-regex": "^1.2.1",
+        "is-set": "^2.0.3",
         "is-shared-array-buffer": "^1.0.4",
         "is-string": "^1.1.1",
         "is-typed-array": "^1.1.15",
-        "is-weakref": "^1.1.0",
+        "is-weakref": "^1.1.1",
         "math-intrinsics": "^1.1.0",
-        "object-inspect": "^1.13.3",
+        "object-inspect": "^1.13.4",
         "object-keys": "^1.1.1",
         "object.assign": "^4.1.7",
         "own-keys": "^1.0.1",
-        "regexp.prototype.flags": "^1.5.3",
+        "regexp.prototype.flags": "^1.5.4",
         "safe-array-concat": "^1.1.3",
         "safe-push-apply": "^1.0.0",
         "safe-regex-test": "^1.1.0",
         "set-proto": "^1.0.0",
+        "stop-iteration-iterator": "^1.1.0",
         "string.prototype.trim": "^1.2.10",
         "string.prototype.trimend": "^1.0.9",
         "string.prototype.trimstart": "^1.0.8",
@@ -3853,7 +4369,7 @@
         "typed-array-byte-offset": "^1.0.4",
         "typed-array-length": "^1.0.7",
         "unbox-primitive": "^1.1.0",
-        "which-typed-array": "^1.1.18"
+        "which-typed-array": "^1.1.19"
       },
       "engines": {
         "node": ">= 0.4"
@@ -3943,9 +4459,9 @@
       }
     },
     "node_modules/esbuild": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
-      "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
+      "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==",
       "dev": true,
       "hasInstallScript": true,
       "license": "MIT",
@@ -3956,31 +4472,32 @@
         "node": ">=18"
       },
       "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.25.0",
-        "@esbuild/android-arm": "0.25.0",
-        "@esbuild/android-arm64": "0.25.0",
-        "@esbuild/android-x64": "0.25.0",
-        "@esbuild/darwin-arm64": "0.25.0",
-        "@esbuild/darwin-x64": "0.25.0",
-        "@esbuild/freebsd-arm64": "0.25.0",
-        "@esbuild/freebsd-x64": "0.25.0",
-        "@esbuild/linux-arm": "0.25.0",
-        "@esbuild/linux-arm64": "0.25.0",
-        "@esbuild/linux-ia32": "0.25.0",
-        "@esbuild/linux-loong64": "0.25.0",
-        "@esbuild/linux-mips64el": "0.25.0",
-        "@esbuild/linux-ppc64": "0.25.0",
-        "@esbuild/linux-riscv64": "0.25.0",
-        "@esbuild/linux-s390x": "0.25.0",
-        "@esbuild/linux-x64": "0.25.0",
-        "@esbuild/netbsd-arm64": "0.25.0",
-        "@esbuild/netbsd-x64": "0.25.0",
-        "@esbuild/openbsd-arm64": "0.25.0",
-        "@esbuild/openbsd-x64": "0.25.0",
-        "@esbuild/sunos-x64": "0.25.0",
-        "@esbuild/win32-arm64": "0.25.0",
-        "@esbuild/win32-ia32": "0.25.0",
-        "@esbuild/win32-x64": "0.25.0"
+        "@esbuild/aix-ppc64": "0.27.0",
+        "@esbuild/android-arm": "0.27.0",
+        "@esbuild/android-arm64": "0.27.0",
+        "@esbuild/android-x64": "0.27.0",
+        "@esbuild/darwin-arm64": "0.27.0",
+        "@esbuild/darwin-x64": "0.27.0",
+        "@esbuild/freebsd-arm64": "0.27.0",
+        "@esbuild/freebsd-x64": "0.27.0",
+        "@esbuild/linux-arm": "0.27.0",
+        "@esbuild/linux-arm64": "0.27.0",
+        "@esbuild/linux-ia32": "0.27.0",
+        "@esbuild/linux-loong64": "0.27.0",
+        "@esbuild/linux-mips64el": "0.27.0",
+        "@esbuild/linux-ppc64": "0.27.0",
+        "@esbuild/linux-riscv64": "0.27.0",
+        "@esbuild/linux-s390x": "0.27.0",
+        "@esbuild/linux-x64": "0.27.0",
+        "@esbuild/netbsd-arm64": "0.27.0",
+        "@esbuild/netbsd-x64": "0.27.0",
+        "@esbuild/openbsd-arm64": "0.27.0",
+        "@esbuild/openbsd-x64": "0.27.0",
+        "@esbuild/openharmony-arm64": "0.27.0",
+        "@esbuild/sunos-x64": "0.27.0",
+        "@esbuild/win32-arm64": "0.27.0",
+        "@esbuild/win32-ia32": "0.27.0",
+        "@esbuild/win32-x64": "0.27.0"
       }
     },
     "node_modules/escalade": {
@@ -4006,55 +4523,33 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/escodegen": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
-      "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
-      "dev": true,
-      "license": "BSD-2-Clause",
-      "dependencies": {
-        "esprima": "^4.0.1",
-        "estraverse": "^5.2.0",
-        "esutils": "^2.0.2"
-      },
-      "bin": {
-        "escodegen": "bin/escodegen.js",
-        "esgenerate": "bin/esgenerate.js"
-      },
-      "engines": {
-        "node": ">=6.0"
-      },
-      "optionalDependencies": {
-        "source-map": "~0.6.1"
-      }
-    },
     "node_modules/eslint": {
-      "version": "9.21.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz",
-      "integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==",
+      "version": "9.39.1",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
+      "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.1",
-        "@eslint/config-array": "^0.19.2",
-        "@eslint/core": "^0.12.0",
-        "@eslint/eslintrc": "^3.3.0",
-        "@eslint/js": "9.21.0",
-        "@eslint/plugin-kit": "^0.2.7",
+        "@eslint/config-array": "^0.21.1",
+        "@eslint/config-helpers": "^0.4.2",
+        "@eslint/core": "^0.17.0",
+        "@eslint/eslintrc": "^3.3.1",
+        "@eslint/js": "9.39.1",
+        "@eslint/plugin-kit": "^0.4.1",
         "@humanfs/node": "^0.16.6",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@humanwhocodes/retry": "^0.4.2",
         "@types/estree": "^1.0.6",
-        "@types/json-schema": "^7.0.15",
         "ajv": "^6.12.4",
         "chalk": "^4.0.0",
         "cross-spawn": "^7.0.6",
         "debug": "^4.3.2",
         "escape-string-regexp": "^4.0.0",
-        "eslint-scope": "^8.2.0",
-        "eslint-visitor-keys": "^4.2.0",
-        "espree": "^10.3.0",
+        "eslint-scope": "^8.4.0",
+        "eslint-visitor-keys": "^4.2.1",
+        "espree": "^10.4.0",
         "esquery": "^1.5.0",
         "esutils": "^2.0.2",
         "fast-deep-equal": "^3.1.3",
@@ -4111,9 +4606,9 @@
       }
     },
     "node_modules/eslint-module-utils": {
-      "version": "2.12.0",
-      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
-      "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
+      "version": "2.12.1",
+      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
+      "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -4139,30 +4634,30 @@
       }
     },
     "node_modules/eslint-plugin-import": {
-      "version": "2.31.0",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
-      "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
+      "version": "2.32.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
+      "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@rtsao/scc": "^1.1.0",
-        "array-includes": "^3.1.8",
-        "array.prototype.findlastindex": "^1.2.5",
-        "array.prototype.flat": "^1.3.2",
-        "array.prototype.flatmap": "^1.3.2",
+        "array-includes": "^3.1.9",
+        "array.prototype.findlastindex": "^1.2.6",
+        "array.prototype.flat": "^1.3.3",
+        "array.prototype.flatmap": "^1.3.3",
         "debug": "^3.2.7",
         "doctrine": "^2.1.0",
         "eslint-import-resolver-node": "^0.3.9",
-        "eslint-module-utils": "^2.12.0",
+        "eslint-module-utils": "^2.12.1",
         "hasown": "^2.0.2",
-        "is-core-module": "^2.15.1",
+        "is-core-module": "^2.16.1",
         "is-glob": "^4.0.3",
         "minimatch": "^3.1.2",
         "object.fromentries": "^2.0.8",
         "object.groupby": "^1.0.3",
-        "object.values": "^1.2.0",
+        "object.values": "^1.2.1",
         "semver": "^6.3.1",
-        "string.prototype.trimend": "^1.0.8",
+        "string.prototype.trimend": "^1.0.9",
         "tsconfig-paths": "^3.15.0"
       },
       "engines": {
@@ -4183,9 +4678,9 @@
       }
     },
     "node_modules/eslint-scope": {
-      "version": "8.2.0",
-      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz",
-      "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==",
+      "version": "8.4.0",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+      "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
       "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
@@ -4200,9 +4695,9 @@
       }
     },
     "node_modules/eslint-visitor-keys": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
-      "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
       "dev": true,
       "license": "Apache-2.0",
       "engines": {
@@ -4226,15 +4721,15 @@
       }
     },
     "node_modules/espree": {
-      "version": "10.3.0",
-      "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
-      "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+      "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
       "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
-        "acorn": "^8.14.0",
+        "acorn": "^8.15.0",
         "acorn-jsx": "^5.3.2",
-        "eslint-visitor-keys": "^4.2.0"
+        "eslint-visitor-keys": "^4.2.1"
       },
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4327,29 +4822,38 @@
         "url": "https://github.com/sindresorhus/execa?sponsor=1"
       }
     },
-    "node_modules/exit": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
-      "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==",
+    "node_modules/execa/node_modules/signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/exit-x": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz",
+      "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==",
       "dev": true,
+      "license": "MIT",
       "engines": {
         "node": ">= 0.8.0"
       }
     },
     "node_modules/expect": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
-      "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz",
+      "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==",
       "license": "MIT",
       "dependencies": {
-        "@jest/expect-utils": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "jest-matcher-utils": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0"
+        "@jest/expect-utils": "30.2.0",
+        "@jest/get-type": "30.1.0",
+        "jest-matcher-utils": "30.2.0",
+        "jest-message-util": "30.2.0",
+        "jest-mock": "30.2.0",
+        "jest-util": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/fast-deep-equal": {
@@ -4396,39 +4900,6 @@
         "node": ">=16.0.0"
       }
     },
-    "node_modules/filelist": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
-      "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "minimatch": "^5.0.1"
-      }
-    },
-    "node_modules/filelist/node_modules/brace-expansion": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
-      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^1.0.0"
-      }
-    },
-    "node_modules/filelist/node_modules/minimatch": {
-      "version": "5.1.6",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
-      "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/fill-range": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -4495,20 +4966,21 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/form-data": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
-      "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+    "node_modules/foreground-child": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+      "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
       "dev": true,
-      "license": "MIT",
+      "license": "ISC",
       "dependencies": {
-        "asynckit": "^0.4.0",
-        "combined-stream": "^1.0.8",
-        "es-set-tostringtag": "^2.1.0",
-        "mime-types": "^2.1.12"
+        "cross-spawn": "^7.0.6",
+        "signal-exit": "^4.0.1"
       },
       "engines": {
-        "node": ">= 6"
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
       }
     },
     "node_modules/fs.realpath": {
@@ -4522,6 +4994,7 @@
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
       "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
       "hasInstallScript": true,
       "license": "MIT",
       "optional": true,
@@ -4674,22 +5147,21 @@
       }
     },
     "node_modules/glob": {
-      "version": "7.2.3",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
-      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
-      "deprecated": "Glob versions prior to v9 are no longer supported",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+      "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
       "dev": true,
       "license": "ISC",
       "dependencies": {
-        "fs.realpath": "^1.0.0",
-        "inflight": "^1.0.4",
-        "inherits": "2",
-        "minimatch": "^3.1.1",
-        "once": "^1.3.0",
-        "path-is-absolute": "^1.0.0"
+        "foreground-child": "^3.1.0",
+        "jackspeak": "^3.1.2",
+        "minimatch": "^9.0.4",
+        "minipass": "^7.1.2",
+        "package-json-from-dist": "^1.0.0",
+        "path-scurry": "^1.11.1"
       },
-      "engines": {
-        "node": "*"
+      "bin": {
+        "glob": "dist/esm/bin.mjs"
       },
       "funding": {
         "url": "https://github.com/sponsors/isaacs"
@@ -4708,6 +5180,32 @@
         "node": ">= 6"
       }
     },
+    "node_modules/glob/node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/glob/node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
     "node_modules/globals": {
       "version": "14.0.0",
       "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -4757,6 +5255,28 @@
       "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
       "license": "ISC"
     },
+    "node_modules/handlebars": {
+      "version": "4.7.8",
+      "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
+      "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "minimist": "^1.2.5",
+        "neo-async": "^2.6.2",
+        "source-map": "^0.6.1",
+        "wordwrap": "^1.0.0"
+      },
+      "bin": {
+        "handlebars": "bin/handlebars"
+      },
+      "engines": {
+        "node": ">=0.4.7"
+      },
+      "optionalDependencies": {
+        "uglify-js": "^3.1.4"
+      }
+    },
     "node_modules/has-bigints": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -4858,16 +5378,16 @@
       "license": "ISC"
     },
     "node_modules/html-encoding-sniffer": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
-      "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+      "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "whatwg-encoding": "^2.0.0"
+        "whatwg-encoding": "^3.1.1"
       },
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/html-escaper": {
@@ -4878,32 +5398,31 @@
       "license": "MIT"
     },
     "node_modules/http-proxy-agent": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
-      "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@tootallnate/once": "2",
-        "agent-base": "6",
-        "debug": "4"
+        "agent-base": "^7.1.0",
+        "debug": "^4.3.4"
       },
       "engines": {
-        "node": ">= 6"
+        "node": ">= 14"
       }
     },
     "node_modules/https-proxy-agent": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
-      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "agent-base": "6",
+        "agent-base": "^7.1.2",
         "debug": "4"
       },
       "engines": {
-        "node": ">= 6"
+        "node": ">= 14"
       }
     },
     "node_modules/human-signals": {
@@ -4930,9 +5449,9 @@
       }
     },
     "node_modules/idb-keyval": {
-      "version": "6.2.1",
-      "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
-      "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==",
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
+      "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
       "license": "Apache-2.0"
     },
     "node_modules/ignore": {
@@ -4946,9 +5465,9 @@
       }
     },
     "node_modules/immutable": {
-      "version": "5.0.3",
-      "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
-      "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
+      "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
       "dev": true,
       "license": "MIT"
     },
@@ -5279,6 +5798,19 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/is-negative-zero": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+      "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/is-number": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -5512,9 +6044,9 @@
       }
     },
     "node_modules/istanbul-lib-instrument/node_modules/semver": {
-      "version": "7.7.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
-      "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+      "version": "7.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
       "dev": true,
       "license": "ISC",
       "bin": {
@@ -5540,24 +6072,24 @@
       }
     },
     "node_modules/istanbul-lib-source-maps": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
-      "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+      "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
       "dev": true,
       "license": "BSD-3-Clause",
       "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.23",
         "debug": "^4.1.1",
-        "istanbul-lib-coverage": "^3.0.0",
-        "source-map": "^0.6.1"
+        "istanbul-lib-coverage": "^3.0.0"
       },
       "engines": {
         "node": ">=10"
       }
     },
     "node_modules/istanbul-reports": {
-      "version": "3.1.7",
-      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
-      "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+      "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
       "dev": true,
       "license": "BSD-3-Clause",
       "dependencies": {
@@ -5568,42 +6100,39 @@
         "node": ">=8"
       }
     },
-    "node_modules/jake": {
-      "version": "10.9.2",
-      "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
-      "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
+    "node_modules/jackspeak": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+      "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
       "dev": true,
-      "license": "Apache-2.0",
+      "license": "BlueOak-1.0.0",
       "dependencies": {
-        "async": "^3.2.3",
-        "chalk": "^4.0.2",
-        "filelist": "^1.0.4",
-        "minimatch": "^3.1.2"
+        "@isaacs/cliui": "^8.0.2"
       },
-      "bin": {
-        "jake": "bin/cli.js"
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
       },
-      "engines": {
-        "node": ">=10"
+      "optionalDependencies": {
+        "@pkgjs/parseargs": "^0.11.0"
       }
     },
     "node_modules/jest": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
-      "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz",
+      "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/core": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "import-local": "^3.0.2",
-        "jest-cli": "^29.7.0"
+        "@jest/core": "30.2.0",
+        "@jest/types": "30.2.0",
+        "import-local": "^3.2.0",
+        "jest-cli": "30.2.0"
       },
       "bin": {
         "jest": "bin/jest.js"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       },
       "peerDependencies": {
         "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
@@ -5615,76 +6144,75 @@
       }
     },
     "node_modules/jest-changed-files": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz",
-      "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz",
+      "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "execa": "^5.0.0",
-        "jest-util": "^29.7.0",
+        "execa": "^5.1.1",
+        "jest-util": "30.2.0",
         "p-limit": "^3.1.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-circus": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz",
-      "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz",
+      "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/expect": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/types": "^29.6.3",
+        "@jest/environment": "30.2.0",
+        "@jest/expect": "30.2.0",
+        "@jest/test-result": "30.2.0",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "chalk": "^4.0.0",
+        "chalk": "^4.1.2",
         "co": "^4.6.0",
-        "dedent": "^1.0.0",
-        "is-generator-fn": "^2.0.0",
-        "jest-each": "^29.7.0",
-        "jest-matcher-utils": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-runtime": "^29.7.0",
-        "jest-snapshot": "^29.7.0",
-        "jest-util": "^29.7.0",
+        "dedent": "^1.6.0",
+        "is-generator-fn": "^2.1.0",
+        "jest-each": "30.2.0",
+        "jest-matcher-utils": "30.2.0",
+        "jest-message-util": "30.2.0",
+        "jest-runtime": "30.2.0",
+        "jest-snapshot": "30.2.0",
+        "jest-util": "30.2.0",
         "p-limit": "^3.1.0",
-        "pretty-format": "^29.7.0",
-        "pure-rand": "^6.0.0",
+        "pretty-format": "30.2.0",
+        "pure-rand": "^7.0.0",
         "slash": "^3.0.0",
-        "stack-utils": "^2.0.3"
+        "stack-utils": "^2.0.6"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-cli": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz",
-      "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz",
+      "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/core": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "chalk": "^4.0.0",
-        "create-jest": "^29.7.0",
-        "exit": "^0.1.2",
-        "import-local": "^3.0.2",
-        "jest-config": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "yargs": "^17.3.1"
+        "@jest/core": "30.2.0",
+        "@jest/test-result": "30.2.0",
+        "@jest/types": "30.2.0",
+        "chalk": "^4.1.2",
+        "exit-x": "^0.2.2",
+        "import-local": "^3.2.0",
+        "jest-config": "30.2.0",
+        "jest-util": "30.2.0",
+        "jest-validate": "30.2.0",
+        "yargs": "^17.7.2"
       },
       "bin": {
         "jest": "bin/jest.js"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       },
       "peerDependencies": {
         "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
@@ -5695,6 +6223,16 @@
         }
       }
     },
+    "node_modules/jest-cli/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/jest-cli/node_modules/cliui": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -5742,6 +6280,19 @@
         "node": ">=8"
       }
     },
+    "node_modules/jest-cli/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/jest-cli/node_modules/wrap-ansi": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -5790,117 +6341,120 @@
       }
     },
     "node_modules/jest-config": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz",
-      "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@jest/test-sequencer": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "babel-jest": "^29.7.0",
-        "chalk": "^4.0.0",
-        "ci-info": "^3.2.0",
-        "deepmerge": "^4.2.2",
-        "glob": "^7.1.3",
-        "graceful-fs": "^4.2.9",
-        "jest-circus": "^29.7.0",
-        "jest-environment-node": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "jest-regex-util": "^29.6.3",
-        "jest-resolve": "^29.7.0",
-        "jest-runner": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "micromatch": "^4.0.4",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz",
+      "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.27.4",
+        "@jest/get-type": "30.1.0",
+        "@jest/pattern": "30.0.1",
+        "@jest/test-sequencer": "30.2.0",
+        "@jest/types": "30.2.0",
+        "babel-jest": "30.2.0",
+        "chalk": "^4.1.2",
+        "ci-info": "^4.2.0",
+        "deepmerge": "^4.3.1",
+        "glob": "^10.3.10",
+        "graceful-fs": "^4.2.11",
+        "jest-circus": "30.2.0",
+        "jest-docblock": "30.2.0",
+        "jest-environment-node": "30.2.0",
+        "jest-regex-util": "30.0.1",
+        "jest-resolve": "30.2.0",
+        "jest-runner": "30.2.0",
+        "jest-util": "30.2.0",
+        "jest-validate": "30.2.0",
+        "micromatch": "^4.0.8",
         "parse-json": "^5.2.0",
-        "pretty-format": "^29.7.0",
+        "pretty-format": "30.2.0",
         "slash": "^3.0.0",
         "strip-json-comments": "^3.1.1"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       },
       "peerDependencies": {
         "@types/node": "*",
+        "esbuild-register": ">=3.4.0",
         "ts-node": ">=9.0.0"
       },
       "peerDependenciesMeta": {
         "@types/node": {
           "optional": true
         },
+        "esbuild-register": {
+          "optional": true
+        },
         "ts-node": {
           "optional": true
         }
       }
     },
     "node_modules/jest-diff": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
-      "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz",
+      "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==",
       "license": "MIT",
       "dependencies": {
-        "chalk": "^4.0.0",
-        "diff-sequences": "^29.6.3",
-        "jest-get-type": "^29.6.3",
-        "pretty-format": "^29.7.0"
+        "@jest/diff-sequences": "30.0.1",
+        "@jest/get-type": "30.1.0",
+        "chalk": "^4.1.2",
+        "pretty-format": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-docblock": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz",
-      "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz",
+      "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "detect-newline": "^3.0.0"
+        "detect-newline": "^3.1.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-each": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz",
-      "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz",
+      "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/types": "^29.6.3",
-        "chalk": "^4.0.0",
-        "jest-get-type": "^29.6.3",
-        "jest-util": "^29.7.0",
-        "pretty-format": "^29.7.0"
+        "@jest/get-type": "30.1.0",
+        "@jest/types": "30.2.0",
+        "chalk": "^4.1.2",
+        "jest-util": "30.2.0",
+        "pretty-format": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-environment-jsdom": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz",
-      "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz",
+      "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "@types/jsdom": "^20.0.0",
+        "@jest/environment": "30.2.0",
+        "@jest/environment-jsdom-abstract": "30.2.0",
+        "@types/jsdom": "^21.1.7",
         "@types/node": "*",
-        "jest-mock": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jsdom": "^20.0.0"
+        "jsdom": "^26.1.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       },
       "peerDependencies": {
-        "canvas": "^2.5.0"
+        "canvas": "^3.0.0"
       },
       "peerDependenciesMeta": {
         "canvas": {
@@ -5909,120 +6463,110 @@
       }
     },
     "node_modules/jest-environment-node": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
-      "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz",
+      "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/types": "^29.6.3",
+        "@jest/environment": "30.2.0",
+        "@jest/fake-timers": "30.2.0",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "jest-mock": "^29.7.0",
-        "jest-util": "^29.7.0"
+        "jest-mock": "30.2.0",
+        "jest-util": "30.2.0",
+        "jest-validate": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/jest-get-type": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
-      "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
-      "license": "MIT",
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-haste-map": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz",
-      "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz",
+      "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/types": "^29.6.3",
-        "@types/graceful-fs": "^4.1.3",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "anymatch": "^3.0.3",
-        "fb-watchman": "^2.0.0",
-        "graceful-fs": "^4.2.9",
-        "jest-regex-util": "^29.6.3",
-        "jest-util": "^29.7.0",
-        "jest-worker": "^29.7.0",
-        "micromatch": "^4.0.4",
+        "anymatch": "^3.1.3",
+        "fb-watchman": "^2.0.2",
+        "graceful-fs": "^4.2.11",
+        "jest-regex-util": "30.0.1",
+        "jest-util": "30.2.0",
+        "jest-worker": "30.2.0",
+        "micromatch": "^4.0.8",
         "walker": "^1.0.8"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       },
       "optionalDependencies": {
-        "fsevents": "^2.3.2"
+        "fsevents": "^2.3.3"
       }
     },
     "node_modules/jest-leak-detector": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
-      "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz",
+      "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "jest-get-type": "^29.6.3",
-        "pretty-format": "^29.7.0"
+        "@jest/get-type": "30.1.0",
+        "pretty-format": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-matcher-utils": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz",
-      "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz",
+      "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==",
       "license": "MIT",
       "dependencies": {
-        "chalk": "^4.0.0",
-        "jest-diff": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "pretty-format": "^29.7.0"
+        "@jest/get-type": "30.1.0",
+        "chalk": "^4.1.2",
+        "jest-diff": "30.2.0",
+        "pretty-format": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-message-util": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
-      "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==",
-      "license": "MIT",
-      "dependencies": {
-        "@babel/code-frame": "^7.12.13",
-        "@jest/types": "^29.6.3",
-        "@types/stack-utils": "^2.0.0",
-        "chalk": "^4.0.0",
-        "graceful-fs": "^4.2.9",
-        "micromatch": "^4.0.4",
-        "pretty-format": "^29.7.0",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz",
+      "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@jest/types": "30.2.0",
+        "@types/stack-utils": "^2.0.3",
+        "chalk": "^4.1.2",
+        "graceful-fs": "^4.2.11",
+        "micromatch": "^4.0.8",
+        "pretty-format": "30.2.0",
         "slash": "^3.0.0",
-        "stack-utils": "^2.0.3"
+        "stack-utils": "^2.0.6"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-mock": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz",
-      "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==",
-      "dev": true,
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz",
+      "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==",
       "license": "MIT",
       "dependencies": {
-        "@jest/types": "^29.6.3",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "jest-util": "^29.7.0"
+        "jest-util": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-pnp-resolver": {
@@ -6044,153 +6588,153 @@
       }
     },
     "node_modules/jest-regex-util": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz",
-      "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==",
-      "dev": true,
+      "version": "30.0.1",
+      "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz",
+      "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==",
       "license": "MIT",
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-resolve": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz",
-      "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz",
+      "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "chalk": "^4.0.0",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "jest-pnp-resolver": "^1.2.2",
-        "jest-util": "^29.7.0",
-        "jest-validate": "^29.7.0",
-        "resolve": "^1.20.0",
-        "resolve.exports": "^2.0.0",
-        "slash": "^3.0.0"
+        "chalk": "^4.1.2",
+        "graceful-fs": "^4.2.11",
+        "jest-haste-map": "30.2.0",
+        "jest-pnp-resolver": "^1.2.3",
+        "jest-util": "30.2.0",
+        "jest-validate": "30.2.0",
+        "slash": "^3.0.0",
+        "unrs-resolver": "^1.7.11"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-resolve-dependencies": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz",
-      "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz",
+      "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "jest-regex-util": "^29.6.3",
-        "jest-snapshot": "^29.7.0"
+        "jest-regex-util": "30.0.1",
+        "jest-snapshot": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-runner": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz",
-      "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz",
+      "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/console": "^29.7.0",
-        "@jest/environment": "^29.7.0",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
+        "@jest/console": "30.2.0",
+        "@jest/environment": "30.2.0",
+        "@jest/test-result": "30.2.0",
+        "@jest/transform": "30.2.0",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "chalk": "^4.0.0",
+        "chalk": "^4.1.2",
         "emittery": "^0.13.1",
-        "graceful-fs": "^4.2.9",
-        "jest-docblock": "^29.7.0",
-        "jest-environment-node": "^29.7.0",
-        "jest-haste-map": "^29.7.0",
-        "jest-leak-detector": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-resolve": "^29.7.0",
-        "jest-runtime": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "jest-watcher": "^29.7.0",
-        "jest-worker": "^29.7.0",
+        "exit-x": "^0.2.2",
+        "graceful-fs": "^4.2.11",
+        "jest-docblock": "30.2.0",
+        "jest-environment-node": "30.2.0",
+        "jest-haste-map": "30.2.0",
+        "jest-leak-detector": "30.2.0",
+        "jest-message-util": "30.2.0",
+        "jest-resolve": "30.2.0",
+        "jest-runtime": "30.2.0",
+        "jest-util": "30.2.0",
+        "jest-watcher": "30.2.0",
+        "jest-worker": "30.2.0",
         "p-limit": "^3.1.0",
         "source-map-support": "0.5.13"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-runtime": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz",
-      "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz",
+      "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/environment": "^29.7.0",
-        "@jest/fake-timers": "^29.7.0",
-        "@jest/globals": "^29.7.0",
-        "@jest/source-map": "^29.6.3",
-        "@jest/test-result": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
+        "@jest/environment": "30.2.0",
+        "@jest/fake-timers": "30.2.0",
+        "@jest/globals": "30.2.0",
+        "@jest/source-map": "30.0.1",
+        "@jest/test-result": "30.2.0",
+        "@jest/transform": "30.2.0",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "chalk": "^4.0.0",
-        "cjs-module-lexer": "^1.0.0",
-        "collect-v8-coverage": "^1.0.0",
-        "glob": "^7.1.3",
-        "graceful-fs": "^4.2.9",
-        "jest-haste-map": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-mock": "^29.7.0",
-        "jest-regex-util": "^29.6.3",
-        "jest-resolve": "^29.7.0",
-        "jest-snapshot": "^29.7.0",
-        "jest-util": "^29.7.0",
+        "chalk": "^4.1.2",
+        "cjs-module-lexer": "^2.1.0",
+        "collect-v8-coverage": "^1.0.2",
+        "glob": "^10.3.10",
+        "graceful-fs": "^4.2.11",
+        "jest-haste-map": "30.2.0",
+        "jest-message-util": "30.2.0",
+        "jest-mock": "30.2.0",
+        "jest-regex-util": "30.0.1",
+        "jest-resolve": "30.2.0",
+        "jest-snapshot": "30.2.0",
+        "jest-util": "30.2.0",
         "slash": "^3.0.0",
         "strip-bom": "^4.0.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-snapshot": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
-      "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@babel/core": "^7.11.6",
-        "@babel/generator": "^7.7.2",
-        "@babel/plugin-syntax-jsx": "^7.7.2",
-        "@babel/plugin-syntax-typescript": "^7.7.2",
-        "@babel/types": "^7.3.3",
-        "@jest/expect-utils": "^29.7.0",
-        "@jest/transform": "^29.7.0",
-        "@jest/types": "^29.6.3",
-        "babel-preset-current-node-syntax": "^1.0.0",
-        "chalk": "^4.0.0",
-        "expect": "^29.7.0",
-        "graceful-fs": "^4.2.9",
-        "jest-diff": "^29.7.0",
-        "jest-get-type": "^29.6.3",
-        "jest-matcher-utils": "^29.7.0",
-        "jest-message-util": "^29.7.0",
-        "jest-util": "^29.7.0",
-        "natural-compare": "^1.4.0",
-        "pretty-format": "^29.7.0",
-        "semver": "^7.5.3"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz",
+      "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.27.4",
+        "@babel/generator": "^7.27.5",
+        "@babel/plugin-syntax-jsx": "^7.27.1",
+        "@babel/plugin-syntax-typescript": "^7.27.1",
+        "@babel/types": "^7.27.3",
+        "@jest/expect-utils": "30.2.0",
+        "@jest/get-type": "30.1.0",
+        "@jest/snapshot-utils": "30.2.0",
+        "@jest/transform": "30.2.0",
+        "@jest/types": "30.2.0",
+        "babel-preset-current-node-syntax": "^1.2.0",
+        "chalk": "^4.1.2",
+        "expect": "30.2.0",
+        "graceful-fs": "^4.2.11",
+        "jest-diff": "30.2.0",
+        "jest-matcher-utils": "30.2.0",
+        "jest-message-util": "30.2.0",
+        "jest-util": "30.2.0",
+        "pretty-format": "30.2.0",
+        "semver": "^7.7.2",
+        "synckit": "^0.11.8"
+      },
+      "engines": {
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-snapshot/node_modules/semver": {
-      "version": "7.7.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
-      "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
       "dev": true,
       "license": "ISC",
       "bin": {
@@ -6201,38 +6745,50 @@
       }
     },
     "node_modules/jest-util": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
-      "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz",
+      "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==",
       "license": "MIT",
       "dependencies": {
-        "@jest/types": "^29.6.3",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "chalk": "^4.0.0",
-        "ci-info": "^3.2.0",
-        "graceful-fs": "^4.2.9",
-        "picomatch": "^2.2.3"
+        "chalk": "^4.1.2",
+        "ci-info": "^4.2.0",
+        "graceful-fs": "^4.2.11",
+        "picomatch": "^4.0.2"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+      }
+    },
+    "node_modules/jest-util/node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
     "node_modules/jest-validate": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz",
-      "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz",
+      "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/types": "^29.6.3",
-        "camelcase": "^6.2.0",
-        "chalk": "^4.0.0",
-        "jest-get-type": "^29.6.3",
+        "@jest/get-type": "30.1.0",
+        "@jest/types": "30.2.0",
+        "camelcase": "^6.3.0",
+        "chalk": "^4.1.2",
         "leven": "^3.1.0",
-        "pretty-format": "^29.7.0"
+        "pretty-format": "30.2.0"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-validate/node_modules/camelcase": {
@@ -6249,39 +6805,40 @@
       }
     },
     "node_modules/jest-watcher": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz",
-      "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz",
+      "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@jest/test-result": "^29.7.0",
-        "@jest/types": "^29.6.3",
+        "@jest/test-result": "30.2.0",
+        "@jest/types": "30.2.0",
         "@types/node": "*",
-        "ansi-escapes": "^4.2.1",
-        "chalk": "^4.0.0",
+        "ansi-escapes": "^4.3.2",
+        "chalk": "^4.1.2",
         "emittery": "^0.13.1",
-        "jest-util": "^29.7.0",
-        "string-length": "^4.0.1"
+        "jest-util": "30.2.0",
+        "string-length": "^4.0.2"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-worker": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz",
-      "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz",
+      "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/node": "*",
-        "jest-util": "^29.7.0",
+        "@ungap/structured-clone": "^1.3.0",
+        "jest-util": "30.2.0",
         "merge-stream": "^2.0.0",
-        "supports-color": "^8.0.0"
+        "supports-color": "^8.1.1"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/jest-worker/node_modules/supports-color": {
@@ -6307,9 +6864,9 @@
       "license": "MIT"
     },
     "node_modules/js-yaml": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
-      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+      "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -6320,44 +6877,38 @@
       }
     },
     "node_modules/jsdom": {
-      "version": "20.0.3",
-      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz",
-      "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "abab": "^2.0.6",
-        "acorn": "^8.8.1",
-        "acorn-globals": "^7.0.0",
-        "cssom": "^0.5.0",
-        "cssstyle": "^2.3.0",
-        "data-urls": "^3.0.2",
-        "decimal.js": "^10.4.2",
-        "domexception": "^4.0.0",
-        "escodegen": "^2.0.0",
-        "form-data": "^4.0.0",
-        "html-encoding-sniffer": "^3.0.0",
-        "http-proxy-agent": "^5.0.0",
-        "https-proxy-agent": "^5.0.1",
+      "version": "26.1.0",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+      "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cssstyle": "^4.2.1",
+        "data-urls": "^5.0.0",
+        "decimal.js": "^10.5.0",
+        "html-encoding-sniffer": "^4.0.0",
+        "http-proxy-agent": "^7.0.2",
+        "https-proxy-agent": "^7.0.6",
         "is-potential-custom-element-name": "^1.0.1",
-        "nwsapi": "^2.2.2",
-        "parse5": "^7.1.1",
+        "nwsapi": "^2.2.16",
+        "parse5": "^7.2.1",
+        "rrweb-cssom": "^0.8.0",
         "saxes": "^6.0.0",
         "symbol-tree": "^3.2.4",
-        "tough-cookie": "^4.1.2",
-        "w3c-xmlserializer": "^4.0.0",
+        "tough-cookie": "^5.1.1",
+        "w3c-xmlserializer": "^5.0.0",
         "webidl-conversions": "^7.0.0",
-        "whatwg-encoding": "^2.0.0",
-        "whatwg-mimetype": "^3.0.0",
-        "whatwg-url": "^11.0.0",
-        "ws": "^8.11.0",
-        "xml-name-validator": "^4.0.0"
+        "whatwg-encoding": "^3.1.1",
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^14.1.1",
+        "ws": "^8.18.0",
+        "xml-name-validator": "^5.0.0"
       },
       "engines": {
-        "node": ">=14"
+        "node": ">=18"
       },
       "peerDependencies": {
-        "canvas": "^2.5.0"
+        "canvas": "^3.0.0"
       },
       "peerDependenciesMeta": {
         "canvas": {
@@ -6436,16 +6987,6 @@
         "json-buffer": "3.0.1"
       }
     },
-    "node_modules/kleur": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
-      "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
     "node_modules/leven": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -6487,16 +7028,16 @@
       }
     },
     "node_modules/livereload": {
-      "version": "0.9.3",
-      "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz",
-      "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==",
+      "version": "0.10.3",
+      "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.10.3.tgz",
+      "integrity": "sha512-llSb8HrtSH7ByPFMc8WTTeW3oy++smwgSA8JVGzEn8KiDPESq6jt1M4ZKKkhKTrhn2wvUOadQq4ip10E5daZ3w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "chokidar": "^3.5.0",
-        "livereload-js": "^3.3.1",
-        "opts": ">= 1.2.0",
-        "ws": "^7.4.3"
+        "chokidar": "^4.0.3",
+        "livereload-js": "^4.0.2",
+        "opts": "^2.0.2",
+        "ws": "^8.4.3"
       },
       "bin": {
         "livereload": "bin/livereload.js"
@@ -6506,32 +7047,40 @@
       }
     },
     "node_modules/livereload-js": {
-      "version": "3.4.1",
-      "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz",
-      "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==",
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-4.0.2.tgz",
+      "integrity": "sha512-Fy7VwgQNiOkynYyNBTo3v9hQUhcW5pFAheJN148+DTgpShjsy/22pLHKKwDK5v0kOsZsJBK+6q1PMgLvRmrwFQ==",
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/livereload/node_modules/ws": {
-      "version": "7.5.10",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
-      "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+    "node_modules/livereload/node_modules/chokidar": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+      "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
       "dev": true,
       "license": "MIT",
+      "dependencies": {
+        "readdirp": "^4.0.1"
+      },
       "engines": {
-        "node": ">=8.3.0"
+        "node": ">= 14.16.0"
       },
-      "peerDependencies": {
-        "bufferutil": "^4.0.1",
-        "utf-8-validate": "^5.0.2"
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/livereload/node_modules/readdirp": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+      "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.18.0"
       },
-      "peerDependenciesMeta": {
-        "bufferutil": {
-          "optional": true
-        },
-        "utf-8-validate": {
-          "optional": true
-        }
+      "funding": {
+        "type": "individual",
+        "url": "https://paulmillr.com/funding/"
       }
     },
     "node_modules/load-json-file": {
@@ -6628,15 +7177,6 @@
         "yallist": "^3.0.2"
       }
     },
-    "node_modules/magic-string": {
-      "version": "0.30.17",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
-      "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/sourcemap-codec": "^1.5.0"
-      }
-    },
     "node_modules/make-dir": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -6654,9 +7194,9 @@
       }
     },
     "node_modules/make-dir/node_modules/semver": {
-      "version": "7.7.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
-      "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
       "dev": true,
       "license": "ISC",
       "bin": {
@@ -6751,29 +7291,6 @@
         "node": ">=8.6"
       }
     },
-    "node_modules/mime-db": {
-      "version": "1.52.0",
-      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
-      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/mime-types": {
-      "version": "2.1.35",
-      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
-      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "mime-db": "1.52.0"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
     "node_modules/mimic-fn": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
@@ -6807,6 +7324,16 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/minipass": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+      "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6814,6 +7341,22 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/napi-postinstall": {
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
+      "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "napi-postinstall": "lib/cli.js"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/napi-postinstall"
+      }
+    },
     "node_modules/natural-compare": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -6821,6 +7364,13 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/neo-async": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/nice-try": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@@ -7074,9 +7624,9 @@
       }
     },
     "node_modules/nwsapi": {
-      "version": "2.2.16",
-      "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz",
-      "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==",
+      "version": "2.2.22",
+      "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
+      "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==",
       "dev": true,
       "license": "MIT"
     },
@@ -7288,6 +7838,13 @@
         "node": ">=6"
       }
     },
+    "node_modules/package-json-from-dist": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+      "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+      "dev": true,
+      "license": "BlueOak-1.0.0"
+    },
     "node_modules/parent-module": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7321,18 +7878,31 @@
       }
     },
     "node_modules/parse5": {
-      "version": "7.2.1",
-      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
-      "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==",
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "entities": "^4.5.0"
+        "entities": "^6.0.0"
       },
       "funding": {
         "url": "https://github.com/inikulin/parse5?sponsor=1"
       }
     },
+    "node_modules/parse5/node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7370,6 +7940,30 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/path-scurry": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+      "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "lru-cache": "^10.2.0",
+        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/path-scurry/node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/path-type": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
@@ -7425,9 +8019,9 @@
       }
     },
     "node_modules/pirates": {
-      "version": "4.0.6",
-      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
-      "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+      "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -7524,17 +8118,17 @@
       }
     },
     "node_modules/pretty-format": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-      "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+      "version": "30.2.0",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz",
+      "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==",
       "license": "MIT",
       "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "ansi-styles": "^5.0.0",
-        "react-is": "^18.0.0"
+        "@jest/schemas": "30.0.5",
+        "ansi-styles": "^5.2.0",
+        "react-is": "^18.3.1"
       },
       "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
     "node_modules/pretty-format/node_modules/ansi-styles": {
@@ -7549,33 +8143,6 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
-    "node_modules/prompts": {
-      "version": "2.4.2",
-      "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
-      "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "kleur": "^3.0.3",
-        "sisteransi": "^1.0.5"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/psl": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
-      "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "punycode": "^2.3.1"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/lupomontero"
-      }
-    },
     "node_modules/punycode": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7596,9 +8163,9 @@
       }
     },
     "node_modules/pure-rand": {
-      "version": "6.1.0",
-      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
-      "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
+      "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==",
       "dev": true,
       "funding": [
         {
@@ -7612,13 +8179,6 @@
       ],
       "license": "MIT"
     },
-    "node_modules/querystringify": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
-      "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/react-is": {
       "version": "18.3.1",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -7714,13 +8274,6 @@
       "dev": true,
       "license": "ISC"
     },
-    "node_modules/requires-port": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
-      "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/resolve": {
       "version": "1.22.10",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -7775,53 +8328,12 @@
         "node": ">=4"
       }
     },
-    "node_modules/resolve.exports": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz",
-      "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==",
+    "node_modules/rrweb-cssom": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+      "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
       "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/rollup": {
-      "version": "3.29.5",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz",
-      "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==",
-      "license": "MIT",
-      "bin": {
-        "rollup": "dist/bin/rollup"
-      },
-      "engines": {
-        "node": ">=14.18.0",
-        "npm": ">=8.0.0"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.2"
-      }
-    },
-    "node_modules/rollup-plugin-dts": {
-      "version": "5.3.1",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-5.3.1.tgz",
-      "integrity": "sha512-gusMi+Z4gY/JaEQeXnB0RUdU82h1kF0WYzCWgVmV4p3hWXqelaKuCvcJawfeg+EKn2T1Ie+YWF2OiN1/L8bTVg==",
-      "license": "LGPL-3.0",
-      "dependencies": {
-        "magic-string": "^0.30.2"
-      },
-      "engines": {
-        "node": ">=v14.21.3"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/Swatinem"
-      },
-      "optionalDependencies": {
-        "@babel/code-frame": "^7.22.5"
-      },
-      "peerDependencies": {
-        "rollup": "^3.0",
-        "typescript": "^4.1 || ^5.0"
-      }
+      "license": "MIT"
     },
     "node_modules/safe-array-concat": {
       "version": "1.1.3",
@@ -7886,9 +8398,9 @@
       "license": "MIT"
     },
     "node_modules/sass": {
-      "version": "1.85.0",
-      "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
-      "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
+      "version": "1.94.2",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz",
+      "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -8039,9 +8551,9 @@
       }
     },
     "node_modules/shell-quote": {
-      "version": "1.8.2",
-      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
-      "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
+      "version": "1.8.3",
+      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
+      "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -8128,18 +8640,17 @@
       }
     },
     "node_modules/signal-exit": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
-      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
-      "dev": true,
-      "license": "ISC"
-    },
-    "node_modules/sisteransi": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
-      "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
       "dev": true,
-      "license": "MIT"
+      "license": "ISC",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
     },
     "node_modules/slash": {
       "version": "3.0.0",
@@ -8151,9 +8662,9 @@
       }
     },
     "node_modules/snabbdom": {
-      "version": "3.6.2",
-      "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.2.tgz",
-      "integrity": "sha512-ig5qOnCDbugFntKi6c7Xlib8bA6xiJVk8O+WdFrV3wxbMqeHO0hXFQC4nAhPVWfZfi8255lcZkNhtIBINCc4+Q==",
+      "version": "3.6.3",
+      "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.3.tgz",
+      "integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
       "license": "MIT",
       "engines": {
         "node": ">=12.17.0"
@@ -8239,54 +8750,147 @@
       "dev": true,
       "license": "BSD-3-Clause"
     },
-    "node_modules/stack-utils": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
-      "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+    "node_modules/stack-utils": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+      "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+      "license": "MIT",
+      "dependencies": {
+        "escape-string-regexp": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/stack-utils/node_modules/escape-string-regexp": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+      "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/stop-iteration-iterator": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+      "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "internal-slot": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/string-length": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
+      "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "char-regex": "^1.0.2",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/string-length/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-length/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
+      "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^7.0.1",
+        "is-fullwidth-code-point": "^2.0.0",
+        "strip-ansi": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/string-width-cjs": {
+      "name": "string-width",
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
-        "escape-string-regexp": "^2.0.0"
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
       },
       "engines": {
-        "node": ">=10"
+        "node": ">=8"
       }
     },
-    "node_modules/stack-utils/node_modules/escape-string-regexp": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
-      "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+    "node_modules/string-width-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=8"
       }
     },
-    "node_modules/string-length": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
-      "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
+    "node_modules/string-width-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
       "dev": true,
       "license": "MIT",
-      "dependencies": {
-        "char-regex": "^1.0.2",
-        "strip-ansi": "^6.0.0"
-      },
       "engines": {
-        "node": ">=10"
+        "node": ">=8"
       }
     },
-    "node_modules/string-width": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
-      "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
+    "node_modules/string-width-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "emoji-regex": "^7.0.1",
-        "is-fullwidth-code-point": "^2.0.0",
-        "strip-ansi": "^5.1.0"
+        "ansi-regex": "^5.0.1"
       },
       "engines": {
-        "node": ">=6"
+        "node": ">=8"
       }
     },
     "node_modules/string-width/node_modules/ansi-regex": {
@@ -8391,6 +8995,23 @@
       }
     },
     "node_modules/strip-ansi": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+      "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/strip-ansi-cjs": {
+      "name": "strip-ansi",
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
       "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
@@ -8403,6 +9024,16 @@
         "node": ">=8"
       }
     },
+    "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/strip-bom": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
@@ -8474,6 +9105,22 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/synckit": {
+      "version": "0.11.11",
+      "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
+      "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@pkgr/core": "^0.2.9"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/synckit"
+      }
+    },
     "node_modules/test-exclude": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -8489,6 +9136,48 @@
         "node": ">=8"
       }
     },
+    "node_modules/test-exclude/node_modules/glob": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "deprecated": "Glob versions prior to v9 are no longer supported",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.1.1",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/tldts": {
+      "version": "6.1.86",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+      "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tldts-core": "^6.1.86"
+      },
+      "bin": {
+        "tldts": "bin/cli.js"
+      }
+    },
+    "node_modules/tldts-core": {
+      "version": "6.1.86",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+      "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/tmpl": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -8509,49 +9198,46 @@
       }
     },
     "node_modules/tough-cookie": {
-      "version": "4.1.4",
-      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
-      "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+      "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
       "dev": true,
       "license": "BSD-3-Clause",
       "dependencies": {
-        "psl": "^1.1.33",
-        "punycode": "^2.1.1",
-        "universalify": "^0.2.0",
-        "url-parse": "^1.5.3"
+        "tldts": "^6.1.32"
       },
       "engines": {
-        "node": ">=6"
+        "node": ">=16"
       }
     },
     "node_modules/tr46": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
-      "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+      "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "punycode": "^2.1.1"
+        "punycode": "^2.3.1"
       },
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/ts-jest": {
-      "version": "29.2.6",
-      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.6.tgz",
-      "integrity": "sha512-yTNZVZqc8lSixm+QGVFcPe6+yj7+TWZwIesuOWvfcn4B9bz5x4NDzVCQQjOs7Hfouu36aEqfEbo9Qpo+gq8dDg==",
+      "version": "29.4.5",
+      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz",
+      "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "bs-logger": "^0.2.6",
-        "ejs": "^3.1.10",
         "fast-json-stable-stringify": "^2.1.0",
-        "jest-util": "^29.0.0",
+        "handlebars": "^4.7.8",
         "json5": "^2.2.3",
         "lodash.memoize": "^4.1.2",
         "make-error": "^1.3.6",
-        "semver": "^7.7.1",
+        "semver": "^7.7.3",
+        "type-fest": "^4.41.0",
         "yargs-parser": "^21.1.1"
       },
       "bin": {
@@ -8562,10 +9248,11 @@
       },
       "peerDependencies": {
         "@babel/core": ">=7.0.0-beta.0 <8",
-        "@jest/transform": "^29.0.0",
-        "@jest/types": "^29.0.0",
-        "babel-jest": "^29.0.0",
-        "jest": "^29.0.0",
+        "@jest/transform": "^29.0.0 || ^30.0.0",
+        "@jest/types": "^29.0.0 || ^30.0.0",
+        "babel-jest": "^29.0.0 || ^30.0.0",
+        "jest": "^29.0.0 || ^30.0.0",
+        "jest-util": "^29.0.0 || ^30.0.0",
         "typescript": ">=4.3 <6"
       },
       "peerDependenciesMeta": {
@@ -8583,13 +9270,16 @@
         },
         "esbuild": {
           "optional": true
+        },
+        "jest-util": {
+          "optional": true
         }
       }
     },
     "node_modules/ts-jest/node_modules/semver": {
-      "version": "7.7.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
-      "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
       "dev": true,
       "license": "ISC",
       "bin": {
@@ -8599,6 +9289,19 @@
         "node": ">=10"
       }
     },
+    "node_modules/ts-jest/node_modules/type-fest": {
+      "version": "4.41.0",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+      "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+      "dev": true,
+      "license": "(MIT OR CC0-1.0)",
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/ts-node": {
       "version": "10.9.2",
       "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -8679,6 +9382,14 @@
         "node": ">=4"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD",
+      "optional": true
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -8794,9 +9505,10 @@
       }
     },
     "node_modules/typescript": {
-      "version": "5.7.3",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
-      "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+      "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+      "dev": true,
       "license": "Apache-2.0",
       "bin": {
         "tsc": "bin/tsc",
@@ -8812,6 +9524,20 @@
       "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
       "license": "MIT"
     },
+    "node_modules/uglify-js": {
+      "version": "3.19.3",
+      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
+      "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "optional": true,
+      "bin": {
+        "uglifyjs": "bin/uglifyjs"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
     "node_modules/unbox-primitive": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -8832,25 +9558,50 @@
       }
     },
     "node_modules/undici-types": {
-      "version": "6.20.0",
-      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
-      "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+      "version": "7.8.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
+      "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
       "license": "MIT"
     },
-    "node_modules/universalify": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
-      "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
+    "node_modules/unrs-resolver": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
+      "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
       "dev": true,
+      "hasInstallScript": true,
       "license": "MIT",
-      "engines": {
-        "node": ">= 4.0.0"
+      "dependencies": {
+        "napi-postinstall": "^0.3.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/unrs-resolver"
+      },
+      "optionalDependencies": {
+        "@unrs/resolver-binding-android-arm-eabi": "1.11.1",
+        "@unrs/resolver-binding-android-arm64": "1.11.1",
+        "@unrs/resolver-binding-darwin-arm64": "1.11.1",
+        "@unrs/resolver-binding-darwin-x64": "1.11.1",
+        "@unrs/resolver-binding-freebsd-x64": "1.11.1",
+        "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
+        "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
+        "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
+        "@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
+        "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
+        "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
+        "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
+        "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
+        "@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
+        "@unrs/resolver-binding-linux-x64-musl": "1.11.1",
+        "@unrs/resolver-binding-wasm32-wasi": "1.11.1",
+        "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
+        "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
+        "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
       }
     },
     "node_modules/update-browserslist-db": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
-      "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+      "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
       "dev": true,
       "funding": [
         {
@@ -8888,17 +9639,6 @@
         "punycode": "^2.1.0"
       }
     },
-    "node_modules/url-parse": {
-      "version": "1.5.10",
-      "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
-      "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "querystringify": "^2.1.1",
-        "requires-port": "^1.0.0"
-      }
-    },
     "node_modules/v8-compile-cache-lib": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@@ -8939,16 +9679,16 @@
       "license": "MIT"
     },
     "node_modules/w3c-xmlserializer": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
-      "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+      "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "xml-name-validator": "^4.0.0"
+        "xml-name-validator": "^5.0.0"
       },
       "engines": {
-        "node": ">=14"
+        "node": ">=18"
       }
     },
     "node_modules/walker": {
@@ -8972,40 +9712,40 @@
       }
     },
     "node_modules/whatwg-encoding": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
-      "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+      "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "iconv-lite": "0.6.3"
       },
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/whatwg-mimetype": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
-      "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
       "dev": true,
       "license": "MIT",
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/whatwg-url": {
-      "version": "11.0.0",
-      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
-      "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
+      "version": "14.2.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+      "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "tr46": "^3.0.0",
+        "tr46": "^5.1.0",
         "webidl-conversions": "^7.0.0"
       },
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/which": {
@@ -9099,16 +9839,17 @@
       "license": "ISC"
     },
     "node_modules/which-typed-array": {
-      "version": "1.1.18",
-      "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz",
-      "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==",
+      "version": "1.1.19",
+      "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
+      "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "available-typed-arrays": "^1.0.7",
         "call-bind": "^1.0.8",
-        "call-bound": "^1.0.3",
-        "for-each": "^0.3.3",
+        "call-bound": "^1.0.4",
+        "for-each": "^0.3.5",
+        "get-proto": "^1.0.1",
         "gopd": "^1.2.0",
         "has-tostringtag": "^1.0.2"
       },
@@ -9129,6 +9870,13 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/wordwrap": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+      "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/wrap-ansi": {
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
@@ -9144,6 +9892,80 @@
         "node": ">=6"
       }
     },
+    "node_modules/wrap-ansi-cjs": {
+      "name": "wrap-ansi",
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/wrap-ansi/node_modules/ansi-regex": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
@@ -9205,23 +10027,23 @@
       "license": "ISC"
     },
     "node_modules/write-file-atomic": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz",
-      "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==",
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
+      "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
       "dev": true,
       "license": "ISC",
       "dependencies": {
         "imurmurhash": "^0.1.4",
-        "signal-exit": "^3.0.7"
+        "signal-exit": "^4.0.1"
       },
       "engines": {
-        "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+        "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
       }
     },
     "node_modules/ws": {
-      "version": "8.18.1",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
-      "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
+      "version": "8.18.3",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+      "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -9241,13 +10063,13 @@
       }
     },
     "node_modules/xml-name-validator": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
-      "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
       "dev": true,
       "license": "Apache-2.0",
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/xmlchars": {
diff --git a/package.json b/package.json
index 5d94537d1..964e3ee7e 100644
--- a/package.json
+++ b/package.json
@@ -19,45 +19,46 @@
     "test": "jest"
   },
   "devDependencies": {
-    "@eslint/js": "^9.21.0",
-    "@lezer/generator": "^1.7.2",
-    "@types/sortablejs": "^1.15.8",
+    "@eslint/js": "^9.39.1",
+    "@lezer/generator": "^1.8.0",
+    "@types/markdown-it": "^14.1.2",
+    "@types/sortablejs": "^1.15.9",
     "chokidar-cli": "^3.0",
-    "esbuild": "^0.25.0",
-    "eslint": "^9.21.0",
-    "eslint-plugin-import": "^2.31.0",
-    "jest": "^29.7.0",
-    "jest-environment-jsdom": "^29.7.0",
-    "livereload": "^0.9.3",
+    "esbuild": "^0.27.0",
+    "eslint": "^9.39.1",
+    "eslint-plugin-import": "^2.32.0",
+    "jest": "^30.2.0",
+    "jest-environment-jsdom": "^30.2.0",
+    "livereload": "^0.10.3",
     "npm-run-all": "^4.1.5",
-    "sass": "^1.85.0",
-    "ts-jest": "^29.2.6",
+    "sass": "^1.94.2",
+    "ts-jest": "^29.4.5",
     "ts-node": "^10.9.2",
-    "typescript": "5.7.*"
+    "typescript": "5.9.*"
   },
   "dependencies": {
-    "@codemirror/commands": "^6.8.0",
+    "@codemirror/commands": "^6.10.0",
     "@codemirror/lang-css": "^6.3.1",
-    "@codemirror/lang-html": "^6.4.9",
-    "@codemirror/lang-javascript": "^6.2.3",
-    "@codemirror/lang-json": "^6.0.1",
-    "@codemirror/lang-markdown": "^6.3.2",
-    "@codemirror/lang-php": "^6.0.1",
+    "@codemirror/lang-html": "^6.4.11",
+    "@codemirror/lang-javascript": "^6.2.4",
+    "@codemirror/lang-json": "^6.0.2",
+    "@codemirror/lang-markdown": "^6.5.0",
+    "@codemirror/lang-php": "^6.0.2",
     "@codemirror/lang-xml": "^6.1.0",
-    "@codemirror/language": "^6.10.8",
-    "@codemirror/legacy-modes": "^6.4.3",
+    "@codemirror/language": "^6.11.3",
+    "@codemirror/legacy-modes": "^6.5.2",
     "@codemirror/state": "^6.5.2",
-    "@codemirror/theme-one-dark": "^6.1.2",
-    "@codemirror/view": "^6.36.3",
-    "@lezer/highlight": "^1.2.1",
+    "@codemirror/theme-one-dark": "^6.1.3",
+    "@codemirror/view": "^6.38.8",
+    "@lezer/highlight": "^1.2.3",
     "@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
     "@ssddanbrown/codemirror-lang-twig": "^1.0.0",
-    "@types/jest": "^29.5.14",
-    "codemirror": "^6.0.1",
-    "idb-keyval": "^6.2.1",
+    "@types/jest": "^30.0.0",
+    "codemirror": "^6.0.2",
+    "idb-keyval": "^6.2.2",
     "markdown-it": "^14.1.0",
     "markdown-it-task-lists": "^2.1.1",
-    "snabbdom": "^3.6.2",
+    "snabbdom": "^3.6.3",
     "sortablejs": "^1.15.6"
   }
 }
diff --git a/phpcs.xml b/phpcs.xml
index 8d4c6b702..8337d5aac 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -14,7 +14,7 @@
     <arg name="parallel" value="75"/>
     <arg value="np"/>
 
-    <rule  ref="PSR12"/>
+    <rule ref="PSR12"/>
 
     <rule ref="PSR1.Methods.CamelCapsMethodName">
         <exclude-pattern>./tests/*</exclude-pattern>
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 0f2021383..72189222f 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -7,7 +7,7 @@ parameters:
         - app
 
     # The level 8 is the highest level
-    level: 1
+    level: 3
 
     phpVersion:
         min: 80200
diff --git a/phpunit.xml b/phpunit.xml
index b72c6e5f1..8a7ab9cb7 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,5 +1,9 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true">
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
+         bootstrap="vendor/autoload.php"
+         displayDetailsOnTestsThatTriggerDeprecations="true"
+         colors="true">
   <testsuites>
     <testsuite name="Application Test Suite">
       <directory>./tests/</directory>
@@ -12,6 +16,8 @@
     <server name="APP_THEME" value="none"/>
     <server name="APP_AUTO_LANG_PUBLIC" value="true"/>
     <server name="APP_URL" value="http://bookstack.dev"/>
+    <server name="APP_TIMEZONE" value="UTC"/>
+    <server name="APP_DISPLAY_TIMEZONE" value="UTC"/>
     <server name="ALLOWED_IFRAME_HOSTS" value=""/>
     <server name="ALLOWED_IFRAME_SOURCES" value="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"/>
     <server name="ALLOWED_SSR_HOSTS" value="*"/>
@@ -21,6 +27,7 @@
     <server name="DB_CONNECTION" value="mysql_testing"/>
     <server name="BCRYPT_ROUNDS" value="4"/>
     <server name="MAIL_DRIVER" value="array"/>
+    <server name="MAIL_PORT" value="587"/>
     <server name="MAIL_VERIFY_SSL" value="true"/>
     <server name="LOG_CHANNEL" value="single"/>
     <server name="AUTH_METHOD" value="standard"/>
diff --git a/readme.md b/readme.md
index d6641a53b..3ee5f242a 100644
--- a/readme.md
+++ b/readme.md
@@ -5,13 +5,13 @@
 [![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack)
 [![Build Status](https://github.com/BookStackApp/BookStack/workflows/test-php/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
 [![Lint Status](https://github.com/BookStackApp/BookStack/workflows/lint-php/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
-[![Maintainability](https://api.codeclimate.com/v1/badges/5551731994dd22fa1f4f/maintainability)](https://codeclimate.com/github/BookStackApp/BookStack/maintainability)
-
+[![php-metrics](https://img.shields.io/static/v1?label=Metrics&message=php&color=4F5B93)](https://source.bookstackapp.com/php-stats/index.html)
+<br>
 [![Alternate Source](https://img.shields.io/static/v1?label=Alt+Source&message=Git&color=ef391a&logo=git)](https://source.bookstackapp.com/)
 [![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/)
-[![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
-[![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://fosstodon.org/@bookstack)
-
+[![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://www.bookstackapp.com/links/discord)
+[![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://www.bookstackapp.com/links/mastodon)
+<br>
 [![PeerTube](https://img.shields.io/static/v1?label=PeerTube&message=bookstack@foss.video&color=f2690d&logo=peertube)](https://foss.video/c/bookstack)
 [![YouTube](https://img.shields.io/static/v1?label=YouTube&message=bookstackapp&color=ff0000&logo=youtube)](https://www.youtube.com/bookstackapp)
 
@@ -24,7 +24,7 @@ A platform for storing and organising information and documentation. Details for
 * [Screenshots](https://www.bookstackapp.com/#screenshots) 
 * [BookStack Blog](https://www.bookstackapp.com/blog)
 * [Issue List](https://github.com/BookStackApp/BookStack/issues)
-* [Discord Chat](https://discord.gg/ztkBqR2)
+* [Discord Chat](https://www.bookstackapp.com/links/discord)
 * [Support Options](https://www.bookstackapp.com/support/)
 
 ## 📚 Project Definition
@@ -48,43 +48,56 @@ Big thanks to these companies for supporting the project.
 #### Gold Sponsor
 
 <table><tbody><tr>
-<td align="center"><a href="https://www.federated.computer/bookstack-wiki" target="_blank">
+<td align="center"><a href="https://www.federated.computer/bookstack" target="_blank">
     <img width="480" src="https://www.bookstackapp.com/images/sponsors/federated-computer.png" alt="Federated.computer">
 </a></td>
-</tr></tbody></table>
+</tr><tr>
+<td align="center"><a href="https://www.diagrams.net/" target="_blank">
+    <img width="480" src="https://www.bookstackapp.com/images/sponsors/diagramsnet.png" alt="Diagrams.net">
+</a></td>
+</tr>
+</tbody></table>
 
 #### Bronze Sponsors
 
 <table><tbody><tr>
-<td align="center"><a href="https://www.diagrams.net/" target="_blank">
-    <img width="240" src="https://www.bookstackapp.com/images/sponsors/diagramsnet.png" alt="Diagrams.net">
-</a></td>
 <td align="center"><a href="https://cloudabove.com/hosting" target="_blank">
     <img width="200" src="https://www.bookstackapp.com/images/sponsors/cloudabove.png" alt="Cloudabove">
 </a></td>
-</tr><tr>
 <td align="center"><a href="https://www.practicali.be" target="_blank">
     <img width="240" src="https://www.bookstackapp.com/images/sponsors/practicali.png" alt="Practicali">
 </a></td>
+</tr><tr>
 <td align="center"><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
     <img width="240" src="https://www.bookstackapp.com/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
 </a></td>
-</tr>
-<tr>
 <td align="center" style="text-align: center"><a href="https://nws.netways.de/apps/bookstack/" target="_blank">
     <img width="240" src="https://www.bookstackapp.com/images/sponsors/netways.png" alt="NETWAYS Web Services">
 </a></td>
+</tr>
+<tr>
 <td align="center" style="text-align: center"><a href="https://www.schroeck-consulting.de/" target="_blank">
     <img width="200" src="https://www.bookstackapp.com/images/sponsors/schroeck-consulting.png" alt="Schroeck IT Consulting">
 </a></td>
-</tr>
-<tr>
 <td align="center"><a href="https://practinet.be/" target="_blank">
     <img width="240" src="https://www.bookstackapp.com/images/sponsors/practinet.png" alt="Practinet">
 </a></td>
+</tr>
+<tr>
 <td align="center"><a href="https://route4me.com/" target="_blank">
     <img width="240" src="https://www.bookstackapp.com/images/sponsors/route4me.png" alt="Route4Me - Route Optimizer and Route Planner Software">
 </a></td>
+<td align="center"><a href="https://phamos.eu" target="_blank">
+    <img width="132" src="https://www.bookstackapp.com/images/sponsors/phamos.png" alt="phamos">
+</a></td>
+</tr>
+<tr>
+<td align="center"><a href="https://sitespeak.ai/bookstack" target="_blank">
+    <img width="240" src="https://www.bookstackapp.com/images/sponsors/sitespeak.png" alt="SiteSpeakAI">
+</a></td>
+<td align="center"><a href="https://www.admin-intelligence.de/bookstack/" target="_blank">
+    <img width="210" src="https://www.bookstackapp.com/images/sponsors/admin-intelligence.png" alt="Admin Intelligence">
+</a></td>
 </tr>
 </tbody></table>
 
@@ -98,7 +111,7 @@ Details about BookStack's versioning scheme and the general release process [can
 
 ## 🌎 Translations
 
-Translations for text within BookStack is managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables such as `:userName`. Leave these values as they are as they will be replaced at run-time.
+Translations for text within BookStack are managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables such as `:userName`. Leave these values as they are as they will be replaced at run-time.
 
 Please use [Crowdin](https://crowdin.com/project/bookstack) to contribute translations instead of opening a pull request. The translations within the working codebase can be out-of-date, and merging via code can cause conflicts & sync issues. If for some reason you can't use Crowdin feel free to open an issue to discuss alternative options. 
 
diff --git a/resources/icons/archive.svg b/resources/icons/archive.svg
new file mode 100644
index 000000000..90a4f35b7
--- /dev/null
+++ b/resources/icons/archive.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m480-240 160-160-56-56-64 64v-168h-80v168l-64-64-56 56 160 160ZM200-640v440h560v-440H200Zm0 520q-33 0-56.5-23.5T120-200v-499q0-14 4.5-27t13.5-24l50-61q11-14 27.5-21.5T250-840h460q18 0 34.5 7.5T772-811l50 61q9 11 13.5 24t4.5 27v499q0 33-23.5 56.5T760-120H200Zm16-600h528l-34-40H250l-34 40Zm264 300Z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/bookmark.svg b/resources/icons/bookmark.svg
new file mode 100644
index 000000000..30e487c52
--- /dev/null
+++ b/resources/icons/bookmark.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120v-640q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v640L480-240 200-120Zm80-122 200-86 200 86v-518H280v518Zm0-518h400-400Z"/></svg>
\ No newline at end of file
diff --git a/resources/js/code/languages.js b/resources/js/code/languages.js
index da9631a13..52fc55dbf 100644
--- a/resources/js/code/languages.js
+++ b/resources/js/code/languages.js
@@ -33,6 +33,7 @@ const modeMap = {
     'f#': () => legacyLoad('fSharp'),
     fsharp: () => legacyLoad('fSharp'),
     go: () => legacyLoad('go'),
+    groovy: () => legacyLoad('groovy'),
     haskell: () => legacyLoad('haskell'),
     hs: () => legacyLoad('haskell'),
     html: async () => html({selfClosingTags: true}),
diff --git a/resources/js/code/legacy-modes.mjs b/resources/js/code/legacy-modes.mjs
index 7b70bec10..a5878ccab 100644
--- a/resources/js/code/legacy-modes.mjs
+++ b/resources/js/code/legacy-modes.mjs
@@ -5,6 +5,8 @@ export {clojure} from '@codemirror/legacy-modes/mode/clojure';
 export {diff} from '@codemirror/legacy-modes/mode/diff';
 export {fortran} from '@codemirror/legacy-modes/mode/fortran';
 export {go} from '@codemirror/legacy-modes/mode/go';
+export {groovy} from '@codemirror/legacy-modes/mode/groovy';
+export {haxe} from '@codemirror/legacy-modes/mode/haxe';
 export {haskell} from '@codemirror/legacy-modes/mode/haskell';
 export {julia} from '@codemirror/legacy-modes/mode/julia';
 export {lua} from '@codemirror/legacy-modes/mode/lua';
diff --git a/resources/js/components/api-nav.ts b/resources/js/components/api-nav.ts
new file mode 100644
index 000000000..a84c1cb33
--- /dev/null
+++ b/resources/js/components/api-nav.ts
@@ -0,0 +1,32 @@
+import {Component} from "./component";
+
+export class ApiNav extends Component {
+    private select!: HTMLSelectElement;
+    private sidebar!: HTMLElement;
+    private body!: HTMLElement;
+
+    setup() {
+        this.select = this.$refs.select as HTMLSelectElement;
+        this.sidebar = this.$refs.sidebar;
+        this.body = this.$el.ownerDocument.documentElement;
+        this.select.addEventListener('change', () => {
+            const section = this.select.value;
+            const sidebarTarget = document.getElementById(`sidebar-header-${section}`);
+            const contentTarget = document.getElementById(`section-${section}`);
+            if (sidebarTarget && contentTarget) {
+
+                const sidebarPos = sidebarTarget.getBoundingClientRect().top - this.sidebar.getBoundingClientRect().top + this.sidebar.scrollTop;
+                this.sidebar.scrollTo({
+                    top: sidebarPos - 120,
+                    behavior: 'smooth',
+                });
+
+                const bodyPos = contentTarget.getBoundingClientRect().top + this.body.scrollTop;
+                this.body.scrollTo({
+                    top: bodyPos - 20,
+                    behavior: 'smooth',
+                });
+            }
+        });
+    }
+}
\ No newline at end of file
diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js
index 5dd5dd93b..d2b044ee1 100644
--- a/resources/js/components/dropdown.js
+++ b/resources/js/components/dropdown.js
@@ -1,4 +1,4 @@
-import {onSelect} from '../services/dom.ts';
+import {findClosestScrollContainer, onSelect} from '../services/dom.ts';
 import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts';
 import {Component} from './component';
 
@@ -33,7 +33,8 @@ export class Dropdown extends Component {
         const menuOriginalRect = this.menu.getBoundingClientRect();
         let heightOffset = 0;
         const toggleHeight = this.toggle.getBoundingClientRect().height;
-        const dropUpwards = menuOriginalRect.bottom > window.innerHeight;
+        const containerBounds = findClosestScrollContainer(this.menu).getBoundingClientRect();
+        const dropUpwards = menuOriginalRect.bottom > containerBounds.bottom;
         const containerRect = this.container.getBoundingClientRect();
 
         // If enabled, Move to body to prevent being trapped within scrollable sections
diff --git a/resources/js/components/editor-toolbox.js b/resources/js/components/editor-toolbox.ts
similarity index 57%
rename from resources/js/components/editor-toolbox.js
rename to resources/js/components/editor-toolbox.ts
index ddb4ff39c..60bdde05e 100644
--- a/resources/js/components/editor-toolbox.js
+++ b/resources/js/components/editor-toolbox.ts
@@ -1,42 +1,58 @@
 import {Component} from './component';
 
+export interface EditorToolboxChangeEventData {
+    tab: string;
+    open: boolean;
+}
+
 export class EditorToolbox extends Component {
 
+    protected container!: HTMLElement;
+    protected buttons!: HTMLButtonElement[];
+    protected contentElements!: HTMLElement[];
+    protected toggleButton!: HTMLElement;
+    protected editorWrapEl!: HTMLElement;
+
+    protected open: boolean = false;
+    protected tab: string = '';
+
     setup() {
         // Elements
         this.container = this.$el;
-        this.buttons = this.$manyRefs.tabButton;
+        this.buttons = this.$manyRefs.tabButton as HTMLButtonElement[];
         this.contentElements = this.$manyRefs.tabContent;
         this.toggleButton = this.$refs.toggle;
-        this.editorWrapEl = this.container.closest('.page-editor');
+        this.editorWrapEl = this.container.closest('.page-editor') as HTMLElement;
 
         this.setupListeners();
 
         // Set the first tab as active on load
-        this.setActiveTab(this.contentElements[0].dataset.tabContent);
+        this.setActiveTab(this.contentElements[0].dataset.tabContent || '');
     }
 
-    setupListeners() {
+    protected setupListeners(): void {
         // Toolbox toggle button click
         this.toggleButton.addEventListener('click', () => this.toggle());
         // Tab button click
-        this.container.addEventListener('click', event => {
-            const button = event.target.closest('button');
-            if (this.buttons.includes(button)) {
-                const name = button.dataset.tab;
+        this.container.addEventListener('click', (event: MouseEvent) => {
+            const button = (event.target as HTMLElement).closest('button');
+            if (button instanceof HTMLButtonElement && this.buttons.includes(button)) {
+                const name = button.dataset.tab || '';
                 this.setActiveTab(name, true);
             }
         });
     }
 
-    toggle() {
+    protected toggle(): void {
         this.container.classList.toggle('open');
         const isOpen = this.container.classList.contains('open');
         this.toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
         this.editorWrapEl.classList.toggle('toolbox-open', isOpen);
+        this.open = isOpen;
+        this.emitState();
     }
 
-    setActiveTab(tabName, openToolbox = false) {
+    protected setActiveTab(tabName: string, openToolbox: boolean = false): void {
         // Set button visibility
         for (const button of this.buttons) {
             button.classList.remove('active');
@@ -54,6 +70,14 @@ export class EditorToolbox extends Component {
         if (openToolbox && !this.container.classList.contains('open')) {
             this.toggle();
         }
+
+        this.tab = tabName;
+        this.emitState();
+    }
+
+    protected emitState(): void {
+        const data: EditorToolboxChangeEventData = {tab: this.tab, open: this.open};
+        this.$emit('change', data);
     }
 
 }
diff --git a/resources/js/components/entity-selector-popup.js b/resources/js/components/entity-selector-popup.ts
similarity index 58%
rename from resources/js/components/entity-selector-popup.js
rename to resources/js/components/entity-selector-popup.ts
index 29c06e909..468f074b5 100644
--- a/resources/js/components/entity-selector-popup.js
+++ b/resources/js/components/entity-selector-popup.ts
@@ -1,15 +1,23 @@
 import {Component} from './component';
+import {EntitySelector, EntitySelectorEntity, EntitySelectorSearchOptions} from "./entity-selector";
+import {Popup} from "./popup";
+
+export type EntitySelectorPopupCallback = (entity: EntitySelectorEntity) => void;
 
 export class EntitySelectorPopup extends Component {
 
+    protected container!: HTMLElement;
+    protected selectButton!: HTMLElement;
+    protected selectorEl!: HTMLElement;
+
+    protected callback: EntitySelectorPopupCallback|null = null;
+    protected selection: EntitySelectorEntity|null = null;
+
     setup() {
         this.container = this.$el;
         this.selectButton = this.$refs.select;
         this.selectorEl = this.$refs.selector;
 
-        this.callback = null;
-        this.selection = null;
-
         this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this));
         window.$events.listen('entity-select-change', this.onSelectionChange.bind(this));
         window.$events.listen('entity-select-confirm', this.handleConfirmedSelection.bind(this));
@@ -17,10 +25,8 @@ export class EntitySelectorPopup extends Component {
 
     /**
      * Show the selector popup.
-     * @param {Function} callback
-     * @param {EntitySelectorSearchOptions} searchOptions
      */
-    show(callback, searchOptions = {}) {
+    show(callback: EntitySelectorPopupCallback, searchOptions: Partial<EntitySelectorSearchOptions> = {}) {
         this.callback = callback;
         this.getSelector().configureSearchOptions(searchOptions);
         this.getPopup().show();
@@ -32,34 +38,28 @@ export class EntitySelectorPopup extends Component {
         this.getPopup().hide();
     }
 
-    /**
-     * @returns {Popup}
-     */
-    getPopup() {
-        return window.$components.firstOnElement(this.container, 'popup');
+    getPopup(): Popup {
+        return window.$components.firstOnElement(this.container, 'popup') as Popup;
     }
 
-    /**
-     * @returns {EntitySelector}
-     */
-    getSelector() {
-        return window.$components.firstOnElement(this.selectorEl, 'entity-selector');
+    getSelector(): EntitySelector {
+        return window.$components.firstOnElement(this.selectorEl, 'entity-selector') as EntitySelector;
     }
 
     onSelectButtonClick() {
         this.handleConfirmedSelection(this.selection);
     }
 
-    onSelectionChange(entity) {
-        this.selection = entity;
-        if (entity === null) {
+    onSelectionChange(entity: EntitySelectorEntity|{}) {
+        this.selection = (entity.hasOwnProperty('id') ? entity : null) as EntitySelectorEntity|null;
+        if (!this.selection) {
             this.selectButton.setAttribute('disabled', 'true');
         } else {
             this.selectButton.removeAttribute('disabled');
         }
     }
 
-    handleConfirmedSelection(entity) {
+    handleConfirmedSelection(entity: EntitySelectorEntity|null): void {
         this.hide();
         this.getSelector().reset();
         if (this.callback && entity) this.callback(entity);
diff --git a/resources/js/components/entity-selector.js b/resources/js/components/entity-selector.ts
similarity index 74%
rename from resources/js/components/entity-selector.js
rename to resources/js/components/entity-selector.ts
index 7491119a1..0ae9710f7 100644
--- a/resources/js/components/entity-selector.js
+++ b/resources/js/components/entity-selector.ts
@@ -1,24 +1,36 @@
-import {onChildEvent} from '../services/dom.ts';
+import {onChildEvent} from '../services/dom';
 import {Component} from './component';
 
-/**
- * @typedef EntitySelectorSearchOptions
- * @property entityTypes string
- * @property entityPermission string
- * @property searchEndpoint string
- * @property initialValue string
- */
-
-/**
- * Entity Selector
- */
+export interface EntitySelectorSearchOptions {
+    entityTypes: string;
+    entityPermission: string;
+    searchEndpoint: string;
+    initialValue: string;
+}
+
+export type EntitySelectorEntity = {
+    id: number,
+    name: string,
+    link: string,
+};
+
 export class EntitySelector extends Component {
+    protected elem!: HTMLElement;
+    protected input!: HTMLInputElement;
+    protected searchInput!: HTMLInputElement;
+    protected loading!: HTMLElement;
+    protected resultsContainer!: HTMLElement;
+
+    protected searchOptions!: EntitySelectorSearchOptions;
+
+    protected search = '';
+    protected lastClick = 0;
 
     setup() {
         this.elem = this.$el;
 
-        this.input = this.$refs.input;
-        this.searchInput = this.$refs.search;
+        this.input = this.$refs.input as HTMLInputElement;
+        this.searchInput = this.$refs.search as HTMLInputElement;
         this.loading = this.$refs.loading;
         this.resultsContainer = this.$refs.results;
 
@@ -29,9 +41,6 @@ export class EntitySelector extends Component {
             initialValue: this.searchInput.value || '',
         };
 
-        this.search = '';
-        this.lastClick = 0;
-
         this.setupListeners();
         this.showLoading();
 
@@ -40,16 +49,13 @@ export class EntitySelector extends Component {
         }
     }
 
-    /**
-     * @param {EntitySelectorSearchOptions} options
-     */
-    configureSearchOptions(options) {
+    configureSearchOptions(options: Partial<EntitySelectorSearchOptions>): void {
         Object.assign(this.searchOptions, options);
         this.reset();
         this.searchInput.value = this.searchOptions.initialValue;
     }
 
-    setupListeners() {
+    setupListeners(): void {
         this.elem.addEventListener('click', this.onClick.bind(this));
 
         let lastSearch = 0;
@@ -67,7 +73,7 @@ export class EntitySelector extends Component {
         });
 
         // Keyboard navigation
-        onChildEvent(this.$el, '[data-entity-type]', 'keydown', event => {
+        onChildEvent(this.$el, '[data-entity-type]', 'keydown', ((event: KeyboardEvent) => {
             if (event.ctrlKey && event.code === 'Enter') {
                 const form = this.$el.closest('form');
                 if (form) {
@@ -83,7 +89,7 @@ export class EntitySelector extends Component {
             if (event.code === 'ArrowUp') {
                 this.focusAdjacent(false);
             }
-        });
+        }) as (event: Event) => void);
 
         this.searchInput.addEventListener('keydown', event => {
             if (event.code === 'ArrowDown') {
@@ -93,10 +99,10 @@ export class EntitySelector extends Component {
     }
 
     focusAdjacent(forward = true) {
-        const items = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
+        const items: (Element|null)[] = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
         const selectedIndex = items.indexOf(document.activeElement);
         const newItem = items[selectedIndex + (forward ? 1 : -1)] || items[0];
-        if (newItem) {
+        if (newItem instanceof HTMLElement) {
             newItem.focus();
         }
     }
@@ -132,7 +138,7 @@ export class EntitySelector extends Component {
         }
 
         window.$http.get(this.searchUrl()).then(resp => {
-            this.resultsContainer.innerHTML = resp.data;
+            this.resultsContainer.innerHTML = resp.data as string;
             this.hideLoading();
         });
     }
@@ -142,7 +148,7 @@ export class EntitySelector extends Component {
         return `${this.searchOptions.searchEndpoint}?${query}`;
     }
 
-    searchEntities(searchTerm) {
+    searchEntities(searchTerm: string) {
         if (!this.searchOptions.searchEndpoint) {
             throw new Error('Search endpoint not set for entity-selector load');
         }
@@ -150,7 +156,7 @@ export class EntitySelector extends Component {
         this.input.value = '';
         const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
         window.$http.get(url).then(resp => {
-            this.resultsContainer.innerHTML = resp.data;
+            this.resultsContainer.innerHTML = resp.data as string;
             this.hideLoading();
         });
     }
@@ -162,16 +168,16 @@ export class EntitySelector extends Component {
         return answer;
     }
 
-    onClick(event) {
-        const listItem = event.target.closest('[data-entity-type]');
-        if (listItem) {
+    onClick(event: MouseEvent) {
+        const listItem = (event.target as HTMLElement).closest('[data-entity-type]');
+        if (listItem instanceof HTMLElement) {
             event.preventDefault();
             event.stopPropagation();
             this.selectItem(listItem);
         }
     }
 
-    selectItem(item) {
+    selectItem(item: HTMLElement): void {
         const isDblClick = this.isDoubleClick();
         const type = item.getAttribute('data-entity-type');
         const id = item.getAttribute('data-entity-id');
@@ -180,14 +186,14 @@ export class EntitySelector extends Component {
         this.unselectAll();
         this.input.value = isSelected ? `${type}:${id}` : '';
 
-        const link = item.getAttribute('href');
-        const name = item.querySelector('.entity-list-item-name').textContent;
-        const data = {id: Number(id), name, link};
+        const link = item.getAttribute('href') || '';
+        const name = item.querySelector('.entity-list-item-name')?.textContent || '';
+        const data: EntitySelectorEntity = {id: Number(id), name, link};
 
         if (isSelected) {
             item.classList.add('selected');
         } else {
-            window.$events.emit('entity-select-change', null);
+            window.$events.emit('entity-select-change');
         }
 
         if (!isDblClick && !isSelected) return;
@@ -200,7 +206,7 @@ export class EntitySelector extends Component {
         }
     }
 
-    confirmSelection(data) {
+    confirmSelection(data: EntitySelectorEntity) {
         window.$events.emit('entity-select-confirm', data);
     }
 
diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js
index c8108ab28..84ba333f9 100644
--- a/resources/js/components/image-manager.js
+++ b/resources/js/components/image-manager.js
@@ -127,6 +127,10 @@ export class ImageManager extends Component {
         });
     }
 
+    /**
+     * @param {({ thumbs: { display: string; }; url: string; name: string; }) => void} callback
+     * @param {String} type
+     */
     show(callback, type = 'gallery') {
         this.resetAll();
 
diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts
index 10b8025db..736d93f05 100644
--- a/resources/js/components/index.ts
+++ b/resources/js/components/index.ts
@@ -1,6 +1,7 @@
 export {AddRemoveRows} from './add-remove-rows';
 export {AjaxDeleteRow} from './ajax-delete-row';
 export {AjaxForm} from './ajax-form';
+export {ApiNav} from './api-nav';
 export {Attachments} from './attachments';
 export {AttachmentsList} from './attachments-list';
 export {AutoSuggest} from './auto-suggest';
@@ -36,6 +37,7 @@ export {NewUserPassword} from './new-user-password';
 export {Notification} from './notification';
 export {OptionalInput} from './optional-input';
 export {PageComment} from './page-comment';
+export {PageCommentReference} from './page-comment-reference';
 export {PageComments} from './page-comments';
 export {PageDisplay} from './page-display';
 export {PageEditor} from './page-editor';
diff --git a/resources/js/components/page-comment-reference.ts b/resources/js/components/page-comment-reference.ts
new file mode 100644
index 000000000..009e806c1
--- /dev/null
+++ b/resources/js/components/page-comment-reference.ts
@@ -0,0 +1,251 @@
+import {Component} from "./component";
+import {findTargetNodeAndOffset, hashElement} from "../services/dom";
+import {el} from "../wysiwyg/utils/dom";
+import commentIcon from "@icons/comment.svg";
+import closeIcon from "@icons/close.svg";
+import {debounce, scrollAndHighlightElement} from "../services/util";
+import {EditorToolboxChangeEventData} from "./editor-toolbox";
+import {TabsChangeEvent} from "./tabs";
+
+/**
+ * Track the close function for the current open marker so it can be closed
+ * when another is opened so we only show one marker comment thread at one time.
+ */
+let openMarkerClose: Function|null = null;
+
+export class PageCommentReference extends Component {
+    protected link!: HTMLLinkElement;
+    protected reference!: string;
+    protected markerWrap: HTMLElement|null = null;
+
+    protected viewCommentText!: string;
+    protected jumpToThreadText!: string;
+    protected closeText!: string;
+
+    setup() {
+        this.link = this.$el as HTMLLinkElement;
+        this.reference = this.$opts.reference;
+        this.viewCommentText = this.$opts.viewCommentText;
+        this.jumpToThreadText = this.$opts.jumpToThreadText;
+        this.closeText = this.$opts.closeText;
+
+        // Show within page display area if seen
+        this.showForDisplay();
+
+        // Handle editor view to show on comments toolbox view
+        window.addEventListener('editor-toolbox-change', ((event: CustomEvent<EditorToolboxChangeEventData>) => {
+            const tabName: string = event.detail.tab;
+            const isOpen = event.detail.open;
+            if (tabName === 'comments' && isOpen && this.link.checkVisibility()) {
+                this.showForEditor();
+            } else {
+                this.hideMarker();
+            }
+        }) as EventListener);
+
+        // Handle visibility changes within editor toolbox archived details dropdown
+        window.addEventListener('toggle', event => {
+            if (event.target instanceof HTMLElement && event.target.contains(this.link)) {
+                window.requestAnimationFrame(() => {
+                    if (this.link.checkVisibility()) {
+                        this.showForEditor();
+                    } else {
+                        this.hideMarker();
+                    }
+                });
+            }
+        }, {capture: true});
+
+        // Handle comments tab changes to hide/show markers & indicators
+        window.addEventListener('tabs-change', ((event: CustomEvent<TabsChangeEvent>) => {
+            const sectionId = event.detail.showing;
+            if (!sectionId.startsWith('comment-tab-panel')) {
+                return;
+            }
+
+            const panel = document.getElementById(sectionId);
+            if (panel?.contains(this.link)) {
+                this.showForDisplay();
+            } else {
+                this.hideMarker();
+            }
+        }) as EventListener);
+    }
+
+    public showForDisplay() {
+        const pageContentArea = document.querySelector('.page-content');
+        if (pageContentArea instanceof HTMLElement && this.link.checkVisibility()) {
+            this.updateMarker(pageContentArea);
+        }
+    }
+
+    protected showForEditor() {
+        const contentWrap = document.querySelector('.editor-content-wrap');
+        if (contentWrap instanceof HTMLElement) {
+            this.updateMarker(contentWrap);
+        }
+
+        const onChange = () => {
+            this.hideMarker();
+            setTimeout(() => {
+                window.$events.remove('editor-html-change', onChange);
+            }, 1);
+        };
+
+        window.$events.listen('editor-html-change', onChange);
+    }
+
+    protected updateMarker(contentContainer: HTMLElement) {
+        // Reset link and existing marker
+        this.link.classList.remove('outdated', 'missing');
+        if (this.markerWrap) {
+            this.markerWrap.remove();
+        }
+
+        const [refId, refHash, refRange] = this.reference.split(':');
+        const refEl = document.getElementById(refId);
+        if (!refEl) {
+            this.link.classList.add('outdated', 'missing');
+            return;
+        }
+
+        const actualHash = hashElement(refEl);
+        if (actualHash !== refHash) {
+            this.link.classList.add('outdated');
+        }
+
+        const marker = el('button', {
+            type: 'button',
+            class: 'content-comment-marker',
+            title: this.viewCommentText,
+        });
+        marker.innerHTML = <string>commentIcon;
+        marker.addEventListener('click', event => {
+            this.showCommentAtMarker(marker);
+        });
+
+        this.markerWrap = el('div', {
+            class: 'content-comment-highlight',
+        }, [marker]);
+
+        contentContainer.append(this.markerWrap);
+        this.positionMarker(refEl, refRange);
+
+        this.link.href = `#${refEl.id}`;
+        this.link.addEventListener('click', (event: MouseEvent) => {
+            event.preventDefault();
+            scrollAndHighlightElement(refEl);
+        });
+
+        const debouncedReposition = debounce(() => {
+            this.positionMarker(refEl, refRange);
+        }, 50, false).bind(this);
+        window.addEventListener('resize', debouncedReposition);
+    }
+
+    protected positionMarker(targetEl: HTMLElement, range: string) {
+        if (!this.markerWrap) {
+            return;
+        }
+
+        const markerParent = this.markerWrap.parentElement as HTMLElement;
+        const parentBounds = markerParent.getBoundingClientRect();
+        let targetBounds = targetEl.getBoundingClientRect();
+        const [rangeStart, rangeEnd] = range.split('-');
+        if (rangeStart && rangeEnd) {
+            const range = new Range();
+            const relStart = findTargetNodeAndOffset(targetEl, Number(rangeStart));
+            const relEnd = findTargetNodeAndOffset(targetEl, Number(rangeEnd));
+            if (relStart && relEnd) {
+                range.setStart(relStart.node, relStart.offset);
+                range.setEnd(relEnd.node, relEnd.offset);
+                targetBounds = range.getBoundingClientRect();
+            }
+        }
+
+        const relLeft = targetBounds.left - parentBounds.left;
+        const relTop = (targetBounds.top - parentBounds.top) + markerParent.scrollTop;
+
+        this.markerWrap.style.left = `${relLeft}px`;
+        this.markerWrap.style.top = `${relTop}px`;
+        this.markerWrap.style.width = `${targetBounds.width}px`;
+        this.markerWrap.style.height = `${targetBounds.height}px`;
+    }
+
+    public hideMarker() {
+        // Hide marker and close existing marker windows
+        if (openMarkerClose) {
+            openMarkerClose();
+        }
+        this.markerWrap?.remove();
+        this.markerWrap = null;
+    }
+
+    protected showCommentAtMarker(marker: HTMLElement): void {
+        // Hide marker and close existing marker windows
+        if (openMarkerClose) {
+            openMarkerClose();
+        }
+        marker.hidden = true;
+
+        // Locate relevant comment
+        const commentBox = this.link.closest('.comment-box') as HTMLElement;
+
+        // Build comment window
+        const readClone = (commentBox.closest('.comment-branch') as HTMLElement).cloneNode(true) as HTMLElement;
+        const toRemove = readClone.querySelectorAll('.actions, form');
+        for (const el of toRemove) {
+            el.remove();
+        }
+
+        const close = el('button', {type: 'button', title: this.closeText});
+        close.innerHTML = (closeIcon as string);
+        const jump = el('button', {type: 'button', 'data-action': 'jump'}, [this.jumpToThreadText]);
+
+        const commentWindow = el('div', {
+            class: 'content-comment-window'
+        }, [
+            el('div', {
+                class: 'content-comment-window-actions',
+            }, [jump, close]),
+            el('div', {
+                class: 'content-comment-window-content comment-container-compact comment-container-super-compact',
+            }, [readClone]),
+        ]);
+
+        marker.parentElement?.append(commentWindow);
+
+        // Handle interaction within window
+        const closeAction = () => {
+            commentWindow.remove();
+            marker.hidden = false;
+            window.removeEventListener('click', windowCloseAction);
+            openMarkerClose = null;
+        };
+
+        const windowCloseAction = (event: MouseEvent) => {
+            if (!(marker.parentElement as HTMLElement).contains(event.target as HTMLElement)) {
+                closeAction();
+            }
+        };
+        window.addEventListener('click', windowCloseAction);
+
+        openMarkerClose = closeAction;
+        close.addEventListener('click', closeAction.bind(this));
+        jump.addEventListener('click', () => {
+            closeAction();
+            commentBox.scrollIntoView({behavior: 'smooth'});
+            const highlightTarget = commentBox.querySelector('.header') as HTMLElement;
+            highlightTarget.classList.add('anim-highlight');
+            highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight'))
+        });
+
+        // Position window within bounds
+        const commentWindowBounds = commentWindow.getBoundingClientRect();
+        const contentBounds = document.querySelector('.page-content')?.getBoundingClientRect();
+        if (contentBounds && commentWindowBounds.right > contentBounds.right) {
+            const diff = commentWindowBounds.right - contentBounds.right;
+            commentWindow.style.left = `-${diff}px`;
+        }
+    }
+}
\ No newline at end of file
diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js
deleted file mode 100644
index 8c0a8b33e..000000000
--- a/resources/js/components/page-comment.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom.ts';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class PageComment extends Component {
-
-    setup() {
-        // Options
-        this.commentId = this.$opts.commentId;
-        this.commentLocalId = this.$opts.commentLocalId;
-        this.commentParentId = this.$opts.commentParentId;
-        this.deletedText = this.$opts.deletedText;
-        this.updatedText = this.$opts.updatedText;
-
-        // Editor reference and text options
-        this.wysiwygEditor = null;
-        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
-        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
-
-        // Element references
-        this.container = this.$el;
-        this.contentContainer = this.$refs.contentContainer;
-        this.form = this.$refs.form;
-        this.formCancel = this.$refs.formCancel;
-        this.editButton = this.$refs.editButton;
-        this.deleteButton = this.$refs.deleteButton;
-        this.replyButton = this.$refs.replyButton;
-        this.input = this.$refs.input;
-
-        this.setupListeners();
-    }
-
-    setupListeners() {
-        if (this.replyButton) {
-            this.replyButton.addEventListener('click', () => this.$emit('reply', {
-                id: this.commentLocalId,
-                element: this.container,
-            }));
-        }
-
-        if (this.editButton) {
-            this.editButton.addEventListener('click', this.startEdit.bind(this));
-            this.form.addEventListener('submit', this.update.bind(this));
-            this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
-        }
-
-        if (this.deleteButton) {
-            this.deleteButton.addEventListener('click', this.delete.bind(this));
-        }
-    }
-
-    toggleEditMode(show) {
-        this.contentContainer.toggleAttribute('hidden', show);
-        this.form.toggleAttribute('hidden', !show);
-    }
-
-    startEdit() {
-        this.toggleEditMode(true);
-
-        if (this.wysiwygEditor) {
-            this.wysiwygEditor.focus();
-            return;
-        }
-
-        const config = buildForInput({
-            language: this.wysiwygLanguage,
-            containerElement: this.input,
-            darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.wysiwygTextDirection,
-            translations: {},
-            translationMap: window.editor_translations,
-        });
-
-        window.tinymce.init(config).then(editors => {
-            this.wysiwygEditor = editors[0];
-            setTimeout(() => this.wysiwygEditor.focus(), 50);
-        });
-    }
-
-    async update(event) {
-        event.preventDefault();
-        const loading = this.showLoading();
-        this.form.toggleAttribute('hidden', true);
-
-        const reqData = {
-            html: this.wysiwygEditor.getContent(),
-            parent_id: this.parentId || null,
-        };
-
-        try {
-            const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
-            const newComment = htmlToDom(resp.data);
-            this.container.replaceWith(newComment);
-            window.$events.success(this.updatedText);
-        } catch (err) {
-            console.error(err);
-            window.$events.showValidationErrors(err);
-            this.form.toggleAttribute('hidden', false);
-            loading.remove();
-        }
-    }
-
-    async delete() {
-        this.showLoading();
-
-        await window.$http.delete(`/comment/${this.commentId}`);
-        this.$emit('delete');
-        this.container.closest('.comment-branch').remove();
-        window.$events.success(this.deletedText);
-    }
-
-    showLoading() {
-        const loading = getLoading();
-        loading.classList.add('px-l');
-        this.container.append(loading);
-        return loading;
-    }
-
-}
diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts
new file mode 100644
index 000000000..8334ebb8a
--- /dev/null
+++ b/resources/js/components/page-comment.ts
@@ -0,0 +1,182 @@
+import {Component} from './component';
+import {getLoading, htmlToDom} from '../services/dom';
+import {PageCommentReference} from "./page-comment-reference";
+import {HttpError} from "../services/http";
+import {SimpleWysiwygEditorInterface} from "../wysiwyg";
+import {el} from "../wysiwyg/utils/dom";
+
+export interface PageCommentReplyEventData {
+    id: string; // ID of comment being replied to
+    element: HTMLElement; // Container for comment replied to
+}
+
+export interface PageCommentArchiveEventData {
+    new_thread_dom: HTMLElement;
+}
+
+export class PageComment extends Component {
+
+    protected commentId!: string;
+    protected commentLocalId!: string;
+    protected deletedText!: string;
+    protected updatedText!: string;
+    protected archiveText!: string;
+
+    protected wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
+    protected wysiwygTextDirection!: string;
+
+    protected container!: HTMLElement;
+    protected contentContainer!: HTMLElement;
+    protected form!: HTMLFormElement;
+    protected formCancel!: HTMLElement;
+    protected editButton!: HTMLElement;
+    protected deleteButton!: HTMLElement;
+    protected replyButton!: HTMLElement;
+    protected archiveButton!: HTMLElement;
+    protected input!: HTMLInputElement;
+
+    setup() {
+        // Options
+        this.commentId = this.$opts.commentId;
+        this.commentLocalId = this.$opts.commentLocalId;
+        this.deletedText = this.$opts.deletedText;
+        this.updatedText = this.$opts.updatedText;
+        this.archiveText = this.$opts.archiveText;
+
+        // Editor reference and text options
+        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+
+        // Element references
+        this.container = this.$el;
+        this.contentContainer = this.$refs.contentContainer;
+        this.form = this.$refs.form as HTMLFormElement;
+        this.formCancel = this.$refs.formCancel;
+        this.editButton = this.$refs.editButton;
+        this.deleteButton = this.$refs.deleteButton;
+        this.replyButton = this.$refs.replyButton;
+        this.archiveButton = this.$refs.archiveButton;
+        this.input = this.$refs.input as HTMLInputElement;
+
+        this.setupListeners();
+    }
+
+    protected setupListeners(): void {
+        if (this.replyButton) {
+            const data: PageCommentReplyEventData = {
+                id: this.commentLocalId,
+                element: this.container,
+            };
+            this.replyButton.addEventListener('click', () => this.$emit('reply', data));
+        }
+
+        if (this.editButton) {
+            this.editButton.addEventListener('click', this.startEdit.bind(this));
+            this.form.addEventListener('submit', this.update.bind(this));
+            this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
+        }
+
+        if (this.deleteButton) {
+            this.deleteButton.addEventListener('click', this.delete.bind(this));
+        }
+
+        if (this.archiveButton) {
+            this.archiveButton.addEventListener('click', this.archive.bind(this));
+        }
+    }
+
+    protected toggleEditMode(show: boolean) : void {
+        this.contentContainer.toggleAttribute('hidden', show);
+        this.form.toggleAttribute('hidden', !show);
+    }
+
+    protected async startEdit(): Promise<void> {
+        this.toggleEditMode(true);
+
+        if (this.wysiwygEditor) {
+            this.wysiwygEditor.focus();
+            return;
+        }
+
+        type WysiwygModule = typeof import('../wysiwyg');
+        const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
+        const editorContent = this.input.value;
+        const container = el('div', {class: 'comment-editor-container'});
+        this.input.parentElement?.appendChild(container);
+        this.input.hidden = true;
+
+        this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, editorContent, {
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.$opts.textDirection,
+            translations: (window as unknown as Record<string, Object>).editor_translations,
+        });
+
+        this.wysiwygEditor.focus();
+    }
+
+    protected async update(event: Event): Promise<void> {
+        event.preventDefault();
+        const loading = this.showLoading();
+        this.form.toggleAttribute('hidden', true);
+
+        const reqData = {
+            html: await this.wysiwygEditor?.getContentAsHtml() || '',
+        };
+
+        try {
+            const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
+            const newComment = htmlToDom(resp.data as string);
+            this.container.replaceWith(newComment);
+            window.$events.success(this.updatedText);
+        } catch (err) {
+            console.error(err);
+            if (err instanceof HttpError) {
+                window.$events.showValidationErrors(err);
+            }
+            this.form.toggleAttribute('hidden', false);
+            loading.remove();
+        }
+    }
+
+    protected async delete(): Promise<void> {
+        this.showLoading();
+
+        await window.$http.delete(`/comment/${this.commentId}`);
+        this.$emit('delete');
+
+        const branch = this.container.closest('.comment-branch');
+        if (branch instanceof HTMLElement) {
+            const refs = window.$components.allWithinElement<PageCommentReference>(branch, 'page-comment-reference');
+            for (const ref of refs) {
+                ref.hideMarker();
+            }
+            branch.remove();
+        }
+
+        window.$events.success(this.deletedText);
+    }
+
+    protected async archive(): Promise<void> {
+        this.showLoading();
+        const isArchived = this.archiveButton.dataset.isArchived === 'true';
+        const action = isArchived ? 'unarchive' : 'archive';
+
+        const response = await window.$http.put(`/comment/${this.commentId}/${action}`);
+        window.$events.success(this.archiveText);
+        const eventData: PageCommentArchiveEventData = {new_thread_dom: htmlToDom(response.data as string)};
+        this.$emit(action, eventData);
+
+        const branch = this.container.closest('.comment-branch') as HTMLElement;
+        const references = window.$components.allWithinElement<PageCommentReference>(branch, 'page-comment-reference');
+        for (const reference of references) {
+            reference.hideMarker();
+        }
+        branch.remove();
+    }
+
+    protected showLoading(): HTMLElement {
+        const loading = getLoading();
+        loading.classList.add('px-l');
+        this.container.append(loading);
+        return loading;
+    }
+}
diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js
deleted file mode 100644
index 8f023836b..000000000
--- a/resources/js/components/page-comments.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import {Component} from './component';
-import {getLoading, htmlToDom} from '../services/dom.ts';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class PageComments extends Component {
-
-    setup() {
-        this.elem = this.$el;
-        this.pageId = Number(this.$opts.pageId);
-
-        // Element references
-        this.container = this.$refs.commentContainer;
-        this.commentCountBar = this.$refs.commentCountBar;
-        this.commentsTitle = this.$refs.commentsTitle;
-        this.addButtonContainer = this.$refs.addButtonContainer;
-        this.replyToRow = this.$refs.replyToRow;
-        this.formContainer = this.$refs.formContainer;
-        this.form = this.$refs.form;
-        this.formInput = this.$refs.formInput;
-        this.formReplyLink = this.$refs.formReplyLink;
-        this.addCommentButton = this.$refs.addCommentButton;
-        this.hideFormButton = this.$refs.hideFormButton;
-        this.removeReplyToButton = this.$refs.removeReplyToButton;
-
-        // WYSIWYG options
-        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
-        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
-        this.wysiwygEditor = null;
-
-        // Translations
-        this.createdText = this.$opts.createdText;
-        this.countText = this.$opts.countText;
-
-        // Internal State
-        this.parentId = null;
-        this.formReplyText = this.formReplyLink?.textContent || '';
-
-        this.setupListeners();
-    }
-
-    setupListeners() {
-        this.elem.addEventListener('page-comment-delete', () => {
-            setTimeout(() => this.updateCount(), 1);
-            this.hideForm();
-        });
-
-        this.elem.addEventListener('page-comment-reply', event => {
-            this.setReply(event.detail.id, event.detail.element);
-        });
-
-        if (this.form) {
-            this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
-            this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
-            this.addCommentButton.addEventListener('click', this.showForm.bind(this));
-            this.form.addEventListener('submit', this.saveComment.bind(this));
-        }
-    }
-
-    saveComment(event) {
-        event.preventDefault();
-        event.stopPropagation();
-
-        const loading = getLoading();
-        loading.classList.add('px-l');
-        this.form.after(loading);
-        this.form.toggleAttribute('hidden', true);
-
-        const reqData = {
-            html: this.wysiwygEditor.getContent(),
-            parent_id: this.parentId || null,
-        };
-
-        window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
-            const newElem = htmlToDom(resp.data);
-
-            if (reqData.parent_id) {
-                this.formContainer.after(newElem);
-            } else {
-                this.container.append(newElem);
-            }
-
-            window.$events.success(this.createdText);
-            this.hideForm();
-            this.updateCount();
-        }).catch(err => {
-            this.form.toggleAttribute('hidden', false);
-            window.$events.showValidationErrors(err);
-        });
-
-        this.form.toggleAttribute('hidden', false);
-        loading.remove();
-    }
-
-    updateCount() {
-        const count = this.getCommentCount();
-        this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count});
-    }
-
-    resetForm() {
-        this.removeEditor();
-        this.formInput.value = '';
-        this.parentId = null;
-        this.replyToRow.toggleAttribute('hidden', true);
-        this.container.append(this.formContainer);
-    }
-
-    showForm() {
-        this.removeEditor();
-        this.formContainer.toggleAttribute('hidden', false);
-        this.addButtonContainer.toggleAttribute('hidden', true);
-        this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
-        this.loadEditor();
-    }
-
-    hideForm() {
-        this.resetForm();
-        this.formContainer.toggleAttribute('hidden', true);
-        if (this.getCommentCount() > 0) {
-            this.elem.append(this.addButtonContainer);
-        } else {
-            this.commentCountBar.append(this.addButtonContainer);
-        }
-        this.addButtonContainer.toggleAttribute('hidden', false);
-    }
-
-    loadEditor() {
-        if (this.wysiwygEditor) {
-            this.wysiwygEditor.focus();
-            return;
-        }
-
-        const config = buildForInput({
-            language: this.wysiwygLanguage,
-            containerElement: this.formInput,
-            darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.wysiwygTextDirection,
-            translations: {},
-            translationMap: window.editor_translations,
-        });
-
-        window.tinymce.init(config).then(editors => {
-            this.wysiwygEditor = editors[0];
-            setTimeout(() => this.wysiwygEditor.focus(), 50);
-        });
-    }
-
-    removeEditor() {
-        if (this.wysiwygEditor) {
-            this.wysiwygEditor.remove();
-            this.wysiwygEditor = null;
-        }
-    }
-
-    getCommentCount() {
-        return this.container.querySelectorAll('[component="page-comment"]').length;
-    }
-
-    setReply(commentLocalId, commentElement) {
-        const targetFormLocation = commentElement.closest('.comment-branch').querySelector('.comment-branch-children');
-        targetFormLocation.append(this.formContainer);
-        this.showForm();
-        this.parentId = commentLocalId;
-        this.replyToRow.toggleAttribute('hidden', false);
-        this.formReplyLink.textContent = this.formReplyText.replace('1234', this.parentId);
-        this.formReplyLink.href = `#comment${this.parentId}`;
-    }
-
-    removeReplyTo() {
-        this.parentId = null;
-        this.replyToRow.toggleAttribute('hidden', true);
-        this.container.append(this.formContainer);
-        this.showForm();
-    }
-
-}
diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts
new file mode 100644
index 000000000..a1eeda1f9
--- /dev/null
+++ b/resources/js/components/page-comments.ts
@@ -0,0 +1,264 @@
+import {Component} from './component';
+import {getLoading, htmlToDom} from '../services/dom';
+import {Tabs} from "./tabs";
+import {PageCommentReference} from "./page-comment-reference";
+import {scrollAndHighlightElement} from "../services/util";
+import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment";
+import {el} from "../wysiwyg/utils/dom";
+import {SimpleWysiwygEditorInterface} from "../wysiwyg";
+
+export class PageComments extends Component {
+
+    private elem!: HTMLElement;
+    private pageId!: number;
+    private container!: HTMLElement;
+    private commentCountBar!: HTMLElement;
+    private activeTab!: HTMLElement;
+    private archivedTab!: HTMLElement;
+    private addButtonContainer!: HTMLElement;
+    private archiveContainer!: HTMLElement;
+    private activeContainer!: HTMLElement;
+    private replyToRow!: HTMLElement;
+    private referenceRow!: HTMLElement;
+    private formContainer!: HTMLElement;
+    private form!: HTMLFormElement;
+    private formInput!: HTMLInputElement;
+    private formReplyLink!: HTMLAnchorElement;
+    private formReferenceLink!: HTMLAnchorElement;
+    private addCommentButton!: HTMLElement;
+    private hideFormButton!: HTMLElement;
+    private removeReplyToButton!: HTMLElement;
+    private removeReferenceButton!: HTMLElement;
+    private wysiwygTextDirection!: string;
+    private wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
+    private createdText!: string;
+    private countText!: string;
+    private archivedCountText!: string;
+    private parentId: number | null = null;
+    private contentReference: string = '';
+    private formReplyText: string = '';
+
+    setup() {
+        this.elem = this.$el;
+        this.pageId = Number(this.$opts.pageId);
+
+        // Element references
+        this.container = this.$refs.commentContainer;
+        this.commentCountBar = this.$refs.commentCountBar;
+        this.activeTab = this.$refs.activeTab;
+        this.archivedTab = this.$refs.archivedTab;
+        this.addButtonContainer = this.$refs.addButtonContainer;
+        this.archiveContainer = this.$refs.archiveContainer;
+        this.activeContainer = this.$refs.activeContainer;
+        this.replyToRow = this.$refs.replyToRow;
+        this.referenceRow = this.$refs.referenceRow;
+        this.formContainer = this.$refs.formContainer;
+        this.form = this.$refs.form as HTMLFormElement;
+        this.formInput = this.$refs.formInput as HTMLInputElement;
+        this.formReplyLink = this.$refs.formReplyLink as HTMLAnchorElement;
+        this.formReferenceLink = this.$refs.formReferenceLink as HTMLAnchorElement;
+        this.addCommentButton = this.$refs.addCommentButton;
+        this.hideFormButton = this.$refs.hideFormButton;
+        this.removeReplyToButton = this.$refs.removeReplyToButton;
+        this.removeReferenceButton = this.$refs.removeReferenceButton;
+
+        // WYSIWYG options
+        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+
+        // Translations
+        this.createdText = this.$opts.createdText;
+        this.countText = this.$opts.countText;
+        this.archivedCountText = this.$opts.archivedCountText;
+
+        this.formReplyText = this.formReplyLink?.textContent || '';
+
+        this.setupListeners();
+    }
+
+    protected setupListeners(): void {
+        this.elem.addEventListener('page-comment-delete', () => {
+            setTimeout(() => {
+                this.updateCount();
+                this.hideForm();
+            }, 1);
+        });
+
+        this.elem.addEventListener('page-comment-reply', ((event: CustomEvent<PageCommentReplyEventData>) => {
+            this.setReply(event.detail.id, event.detail.element);
+        }) as EventListener);
+
+        this.elem.addEventListener('page-comment-archive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
+            this.archiveContainer.append(event.detail.new_thread_dom);
+            setTimeout(() => this.updateCount(), 1);
+        }) as EventListener);
+
+        this.elem.addEventListener('page-comment-unarchive', ((event: CustomEvent<PageCommentArchiveEventData>) => {
+            this.container.append(event.detail.new_thread_dom);
+            setTimeout(() => this.updateCount(), 1);
+        }) as EventListener);
+
+        if (this.form) {
+            this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
+            this.removeReferenceButton.addEventListener('click', () => this.setContentReference(''));
+            this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
+            this.addCommentButton.addEventListener('click', this.showForm.bind(this));
+            this.form.addEventListener('submit', this.saveComment.bind(this));
+        }
+    }
+
+    protected async saveComment(event: SubmitEvent): Promise<void> {
+        event.preventDefault();
+        event.stopPropagation();
+
+        const loading = getLoading();
+        loading.classList.add('px-l');
+        this.form.after(loading);
+        this.form.toggleAttribute('hidden', true);
+
+        const reqData = {
+            html: (await this.wysiwygEditor?.getContentAsHtml()) || '',
+            parent_id: this.parentId || null,
+            content_ref: this.contentReference,
+        };
+
+        window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
+            const newElem = htmlToDom(resp.data as string);
+
+            if (reqData.parent_id) {
+                this.formContainer.after(newElem);
+            } else {
+                this.container.append(newElem);
+            }
+
+            const refs = window.$components.allWithinElement<PageCommentReference>(newElem, 'page-comment-reference');
+            for (const ref of refs) {
+                ref.showForDisplay();
+            }
+
+            window.$events.success(this.createdText);
+            this.hideForm();
+            this.updateCount();
+        }).catch(err => {
+            this.form.toggleAttribute('hidden', false);
+            window.$events.showValidationErrors(err);
+        });
+
+        this.form.toggleAttribute('hidden', false);
+        loading.remove();
+    }
+
+    protected updateCount(): void {
+        const activeCount = this.getActiveThreadCount();
+        this.activeTab.textContent = window.$trans.choice(this.countText, activeCount);
+        const archivedCount = this.getArchivedThreadCount();
+        this.archivedTab.textContent = window.$trans.choice(this.archivedCountText, archivedCount);
+    }
+
+    protected resetForm(): void {
+        this.removeEditor();
+        this.formInput.value = '';
+        this.parentId = null;
+        this.replyToRow.toggleAttribute('hidden', true);
+        this.container.append(this.formContainer);
+        this.setContentReference('');
+    }
+
+    protected showForm(): void {
+        this.removeEditor();
+        this.formContainer.toggleAttribute('hidden', false);
+        this.addButtonContainer.toggleAttribute('hidden', true);
+        this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+        this.loadEditor();
+
+        // Ensure the active comments tab is displaying if that's where we're showing the form
+        const tabs = window.$components.firstOnElement(this.elem, 'tabs');
+        if (tabs instanceof Tabs && this.formContainer.closest('#comment-tab-panel-active')) {
+            tabs.show('comment-tab-panel-active');
+        }
+    }
+
+    protected hideForm(): void {
+        this.resetForm();
+        this.formContainer.toggleAttribute('hidden', true);
+        if (this.getActiveThreadCount() > 0) {
+            this.activeContainer.append(this.addButtonContainer);
+        } else {
+            this.commentCountBar.append(this.addButtonContainer);
+        }
+        this.addButtonContainer.toggleAttribute('hidden', false);
+    }
+
+    protected async loadEditor(): Promise<void> {
+        if (this.wysiwygEditor) {
+            this.wysiwygEditor.focus();
+            return;
+        }
+
+        type WysiwygModule = typeof import('../wysiwyg');
+        const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
+        const container = el('div', {class: 'comment-editor-container'});
+        this.formInput.parentElement?.appendChild(container);
+        this.formInput.hidden = true;
+
+        this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '<p></p>', {
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.wysiwygTextDirection,
+            translations: (window as unknown as Record<string, Object>).editor_translations,
+        });
+
+        this.wysiwygEditor.focus();
+    }
+
+    protected removeEditor(): void {
+        if (this.wysiwygEditor) {
+            this.wysiwygEditor.remove();
+            this.wysiwygEditor = null;
+        }
+    }
+
+    protected getActiveThreadCount(): number {
+        return this.container.querySelectorAll(':scope > .comment-branch:not([hidden])').length;
+    }
+
+    protected getArchivedThreadCount(): number {
+        return this.archiveContainer.querySelectorAll(':scope > .comment-branch').length;
+    }
+
+    protected setReply(commentLocalId: string, commentElement: HTMLElement): void {
+        const targetFormLocation = (commentElement.closest('.comment-branch') as HTMLElement).querySelector('.comment-branch-children') as HTMLElement;
+        targetFormLocation.append(this.formContainer);
+        this.showForm();
+        this.parentId = Number(commentLocalId);
+        this.replyToRow.toggleAttribute('hidden', false);
+        this.formReplyLink.textContent = this.formReplyText.replace('1234', String(this.parentId));
+        this.formReplyLink.href = `#comment${this.parentId}`;
+    }
+
+    protected removeReplyTo(): void {
+        this.parentId = null;
+        this.replyToRow.toggleAttribute('hidden', true);
+        this.container.append(this.formContainer);
+        this.showForm();
+    }
+
+    public startNewComment(contentReference: string): void {
+        this.resetForm();
+        this.showForm();
+        this.setContentReference(contentReference);
+    }
+
+    protected setContentReference(reference: string): void {
+        this.contentReference = reference;
+        this.referenceRow.toggleAttribute('hidden', !Boolean(reference));
+        const [id] = reference.split(':');
+        this.formReferenceLink.href = `#${id}`;
+        this.formReferenceLink.onclick = function(event) {
+            event.preventDefault();
+            const el = document.getElementById(id);
+            if (el) {
+                scrollAndHighlightElement(el);
+            }
+        };
+    }
+
+}
diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js
index 81378e944..542453504 100644
--- a/resources/js/components/page-editor.js
+++ b/resources/js/components/page-editor.js
@@ -25,6 +25,7 @@ export class PageEditor extends Component {
         this.draftDisplayIcon = this.$refs.draftDisplayIcon;
         this.changelogInput = this.$refs.changelogInput;
         this.changelogDisplay = this.$refs.changelogDisplay;
+        this.changelogCounter = this.$refs.changelogCounter;
         this.changeEditorButtons = this.$manyRefs.changeEditor || [];
         this.switchDialogContainer = this.$refs.switchDialog;
         this.deleteDraftDialogContainer = this.$refs.deleteDraftDialog;
@@ -75,7 +76,11 @@ export class PageEditor extends Component {
 
         // Changelog controls
         const updateChangelogDebounced = debounce(this.updateChangelogDisplay.bind(this), 300, false);
-        this.changelogInput.addEventListener('input', updateChangelogDebounced);
+        this.changelogInput.addEventListener('input', () => {
+            const count = this.changelogInput.value.length;
+            this.changelogCounter.innerText = `${count} / 180`;
+            updateChangelogDebounced();
+        });
 
         // Draft Controls
         onSelect(this.saveDraftButton, this.saveDraft.bind(this));
diff --git a/resources/js/components/pointer.js b/resources/js/components/pointer.ts
similarity index 50%
rename from resources/js/components/pointer.js
rename to resources/js/components/pointer.ts
index 292b923e5..4b927045a 100644
--- a/resources/js/components/pointer.js
+++ b/resources/js/components/pointer.ts
@@ -1,25 +1,39 @@
-import * as DOM from '../services/dom.ts';
+import * as DOM from '../services/dom';
 import {Component} from './component';
-import {copyTextToClipboard} from '../services/clipboard.ts';
+import {copyTextToClipboard} from '../services/clipboard';
+import {hashElement, normalizeNodeTextOffsetToParent} from "../services/dom";
+import {PageComments} from "./page-comments";
 
 export class Pointer extends Component {
 
+    protected showing: boolean = false;
+    protected isMakingSelection: boolean = false;
+    protected targetElement: HTMLElement|null = null;
+    protected targetSelectionRange: Range|null = null;
+
+    protected pointer!: HTMLElement;
+    protected linkInput!: HTMLInputElement;
+    protected linkButton!: HTMLElement;
+    protected includeInput!: HTMLInputElement;
+    protected includeButton!: HTMLElement;
+    protected sectionModeButton!: HTMLElement;
+    protected commentButton!: HTMLElement;
+    protected modeToggles!: HTMLElement[];
+    protected modeSections!: HTMLElement[];
+    protected pageId!: string;
+
     setup() {
-        this.container = this.$el;
         this.pointer = this.$refs.pointer;
-        this.linkInput = this.$refs.linkInput;
+        this.linkInput = this.$refs.linkInput as HTMLInputElement;
         this.linkButton = this.$refs.linkButton;
-        this.includeInput = this.$refs.includeInput;
+        this.includeInput = this.$refs.includeInput as HTMLInputElement;
         this.includeButton = this.$refs.includeButton;
         this.sectionModeButton = this.$refs.sectionModeButton;
+        this.commentButton = this.$refs.commentButton;
         this.modeToggles = this.$manyRefs.modeToggle;
         this.modeSections = this.$manyRefs.modeSection;
         this.pageId = this.$opts.pageId;
 
-        // Instance variables
-        this.showing = false;
-        this.isSelection = false;
-
         this.setupListeners();
     }
 
@@ -30,7 +44,7 @@ export class Pointer extends Component {
 
         // Select all contents on input click
         DOM.onSelect([this.includeInput, this.linkInput], event => {
-            event.target.select();
+            (event.target as HTMLInputElement).select();
             event.stopPropagation();
         });
 
@@ -41,7 +55,7 @@ export class Pointer extends Component {
 
         // Hide pointer when clicking away
         DOM.onEvents(document.body, ['click', 'focus'], () => {
-            if (!this.showing || this.isSelection) return;
+            if (!this.showing || this.isMakingSelection) return;
             this.hidePointer();
         });
 
@@ -52,9 +66,10 @@ export class Pointer extends Component {
         const pageContent = document.querySelector('.page-content');
         DOM.onEvents(pageContent, ['mouseup', 'keyup'], event => {
             event.stopPropagation();
-            const targetEl = event.target.closest('[id^="bkmrk"]');
-            if (targetEl && window.getSelection().toString().length > 0) {
-                this.showPointerAtTarget(targetEl, event.pageX, false);
+            const targetEl = (event.target as HTMLElement).closest('[id^="bkmrk"]');
+            if (targetEl instanceof HTMLElement && (window.getSelection() || '').toString().length > 0) {
+                const xPos = (event instanceof MouseEvent) ? event.pageX : 0;
+                this.showPointerAtTarget(targetEl, xPos, false);
             }
         });
 
@@ -63,28 +78,35 @@ export class Pointer extends Component {
 
         // Toggle between pointer modes
         DOM.onSelect(this.modeToggles, event => {
+            const targetToggle = (event.target as HTMLElement);
             for (const section of this.modeSections) {
-                const show = !section.contains(event.target);
+                const show = !section.contains(targetToggle);
                 section.toggleAttribute('hidden', !show);
             }
 
-            this.modeToggles.find(b => b !== event.target).focus();
+            const otherToggle = this.modeToggles.find(b => b !== targetToggle);
+            otherToggle && otherToggle.focus();
         });
+
+        if (this.commentButton) {
+            DOM.onSelect(this.commentButton, this.createCommentAtPointer.bind(this));
+        }
     }
 
     hidePointer() {
-        this.pointer.style.display = null;
+        this.pointer.style.removeProperty('display');
         this.showing = false;
+        this.targetElement = null;
+        this.targetSelectionRange = null;
     }
 
     /**
      * Move and display the pointer at the given element, targeting the given screen x-position if possible.
-     * @param {Element} element
-     * @param {Number} xPosition
-     * @param {Boolean} keyboardMode
      */
-    showPointerAtTarget(element, xPosition, keyboardMode) {
-        this.updateForTarget(element);
+    showPointerAtTarget(element: HTMLElement, xPosition: number, keyboardMode: boolean) {
+        this.targetElement = element;
+        this.targetSelectionRange = window.getSelection()?.getRangeAt(0) || null;
+        this.updateDomForTarget(element);
 
         this.pointer.style.display = 'block';
         const targetBounds = element.getBoundingClientRect();
@@ -98,18 +120,18 @@ export class Pointer extends Component {
         this.pointer.style.top = `${yOffset}px`;
 
         this.showing = true;
-        this.isSelection = true;
+        this.isMakingSelection = true;
 
         setTimeout(() => {
-            this.isSelection = false;
+            this.isMakingSelection = false;
         }, 100);
 
         const scrollListener = () => {
             this.hidePointer();
-            window.removeEventListener('scroll', scrollListener, {passive: true});
+            window.removeEventListener('scroll', scrollListener);
         };
 
-        element.parentElement.insertBefore(this.pointer, element);
+        element.parentElement?.insertBefore(this.pointer, element);
         if (!keyboardMode) {
             window.addEventListener('scroll', scrollListener, {passive: true});
         }
@@ -117,9 +139,8 @@ export class Pointer extends Component {
 
     /**
      * Update the pointer inputs/content for the given target element.
-     * @param {?Element} element
      */
-    updateForTarget(element) {
+    updateDomForTarget(element: HTMLElement) {
         const permaLink = window.baseUrl(`/link/${this.pageId}#${element.id}`);
         const includeTag = `{{@${this.pageId}#${element.id}}}`;
 
@@ -128,18 +149,18 @@ export class Pointer extends Component {
 
         // Update anchor if present
         const editAnchor = this.pointer.querySelector('#pointer-edit');
-        if (editAnchor && element) {
+        if (editAnchor instanceof HTMLAnchorElement && element) {
             const {editHref} = editAnchor.dataset;
             const elementId = element.id;
 
             // Get the first 50 characters.
-            const queryContent = element.textContent && element.textContent.substring(0, 50);
+            const queryContent = (element.textContent || '').substring(0, 50);
             editAnchor.href = `${editHref}?content-id=${elementId}&content-text=${encodeURIComponent(queryContent)}`;
         }
     }
 
     enterSectionSelectMode() {
-        const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]'));
+        const sections = Array.from(document.querySelectorAll('.page-content [id^="bkmrk"]')) as HTMLElement[];
         for (const section of sections) {
             section.setAttribute('tabindex', '0');
         }
@@ -147,9 +168,39 @@ export class Pointer extends Component {
         sections[0].focus();
 
         DOM.onEnterPress(sections, event => {
-            this.showPointerAtTarget(event.target, 0, true);
+            this.showPointerAtTarget(event.target as HTMLElement, 0, true);
             this.pointer.focus();
         });
     }
 
+    createCommentAtPointer() {
+        if (!this.targetElement) {
+            return;
+        }
+
+        const refId = this.targetElement.id;
+        const hash = hashElement(this.targetElement);
+        let range = '';
+        if (this.targetSelectionRange) {
+            const commonContainer = this.targetSelectionRange.commonAncestorContainer;
+            if (this.targetElement.contains(commonContainer)) {
+                const start = normalizeNodeTextOffsetToParent(
+                    this.targetSelectionRange.startContainer,
+                    this.targetSelectionRange.startOffset,
+                    this.targetElement
+                );
+                const end = normalizeNodeTextOffsetToParent(
+                    this.targetSelectionRange.endContainer,
+                    this.targetSelectionRange.endOffset,
+                    this.targetElement
+                );
+                range = `${start}-${end}`;
+            }
+        }
+
+        const reference = `${refId}:${hash}:${range}`;
+        const pageComments = window.$components.first('page-comments') as PageComments;
+        pageComments.startNewComment(reference);
+    }
+
 }
diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.ts
similarity index 74%
rename from resources/js/components/tabs.js
rename to resources/js/components/tabs.ts
index f0fc058ce..a03d37cd4 100644
--- a/resources/js/components/tabs.js
+++ b/resources/js/components/tabs.ts
@@ -1,5 +1,9 @@
 import {Component} from './component';
 
+export interface TabsChangeEvent {
+    showing: string;
+}
+
 /**
  * Tabs
  * Uses accessible attributes to drive its functionality.
@@ -19,18 +23,25 @@ import {Component} from './component';
  */
 export class Tabs extends Component {
 
+    protected container!: HTMLElement;
+    protected tabList!: HTMLElement;
+    protected tabs!: HTMLElement[];
+    protected panels!: HTMLElement[];
+
+    protected activeUnder!: number;
+    protected active: null|boolean = null;
+
     setup() {
         this.container = this.$el;
-        this.tabList = this.container.querySelector('[role="tablist"]');
+        this.tabList = this.container.querySelector('[role="tablist"]') as HTMLElement;
         this.tabs = Array.from(this.tabList.querySelectorAll('[role="tab"]'));
         this.panels = Array.from(this.container.querySelectorAll(':scope > [role="tabpanel"], :scope > * > [role="tabpanel"]'));
         this.activeUnder = this.$opts.activeUnder ? Number(this.$opts.activeUnder) : 10000;
-        this.active = null;
 
         this.container.addEventListener('click', event => {
-            const tab = event.target.closest('[role="tab"]');
-            if (tab && this.tabs.includes(tab)) {
-                this.show(tab.getAttribute('aria-controls'));
+            const tab = (event.target as HTMLElement).closest('[role="tab"]');
+            if (tab instanceof HTMLElement && this.tabs.includes(tab)) {
+                this.show(tab.getAttribute('aria-controls') || '');
             }
         });
 
@@ -40,7 +51,7 @@ export class Tabs extends Component {
         this.updateActiveState();
     }
 
-    show(sectionId) {
+    public show(sectionId: string): void {
         for (const panel of this.panels) {
             panel.toggleAttribute('hidden', panel.id !== sectionId);
         }
@@ -51,10 +62,11 @@ export class Tabs extends Component {
             tab.setAttribute('aria-selected', selected ? 'true' : 'false');
         }
 
-        this.$emit('change', {showing: sectionId});
+        const data: TabsChangeEvent = {showing: sectionId};
+        this.$emit('change', data);
     }
 
-    updateActiveState() {
+    protected updateActiveState(): void {
         const active = window.innerWidth < this.activeUnder;
         if (active === this.active) {
             return;
@@ -69,13 +81,13 @@ export class Tabs extends Component {
         this.active = active;
     }
 
-    activate() {
+    protected activate(): void {
         const panelToShow = this.panels.find(p => !p.hasAttribute('hidden')) || this.panels[0];
         this.show(panelToShow.id);
         this.tabList.toggleAttribute('hidden', false);
     }
 
-    deactivate() {
+    protected deactivate(): void {
         for (const panel of this.panels) {
             panel.removeAttribute('hidden');
         }
diff --git a/resources/js/components/tri-layout.js b/resources/js/components/tri-layout.ts
similarity index 57%
rename from resources/js/components/tri-layout.js
rename to resources/js/components/tri-layout.ts
index be9388e8d..40a2d3691 100644
--- a/resources/js/components/tri-layout.js
+++ b/resources/js/components/tri-layout.ts
@@ -1,18 +1,22 @@
 import {Component} from './component';
 
 export class TriLayout extends Component {
-
-    setup() {
+    private container!: HTMLElement;
+    private tabs!: HTMLElement[];
+    private sidebarScrollContainers!: HTMLElement[];
+
+    private lastLayoutType = 'none';
+    private onDestroy: (()=>void)|null = null;
+    private scrollCache: Record<string, number> = {
+        content: 0,
+        info: 0,
+    };
+    private lastTabShown = 'content';
+
+    setup(): void {
         this.container = this.$refs.container;
         this.tabs = this.$manyRefs.tab;
-
-        this.lastLayoutType = 'none';
-        this.onDestroy = null;
-        this.scrollCache = {
-            content: 0,
-            info: 0,
-        };
-        this.lastTabShown = 'content';
+        this.sidebarScrollContainers = this.$manyRefs.sidebarScrollContainer;
 
         // Bind any listeners
         this.mobileTabClick = this.mobileTabClick.bind(this);
@@ -22,9 +26,11 @@ export class TriLayout extends Component {
         window.addEventListener('resize', () => {
             this.updateLayout();
         }, {passive: true});
+
+        this.setupSidebarScrollHandlers();
     }
 
-    updateLayout() {
+    updateLayout(): void {
         let newLayout = 'tablet';
         if (window.innerWidth <= 1000) newLayout = 'mobile';
         if (window.innerWidth > 1400) newLayout = 'desktop';
@@ -56,16 +62,15 @@ export class TriLayout extends Component {
         };
     }
 
-    setupDesktop() {
+    setupDesktop(): void {
         //
     }
 
     /**
      * Action to run when the mobile info toggle bar is clicked/tapped
-     * @param event
      */
-    mobileTabClick(event) {
-        const {tab} = event.target.dataset;
+    mobileTabClick(event: MouseEvent): void {
+        const tab = (event.target as HTMLElement).dataset.tab || '';
         this.showTab(tab);
     }
 
@@ -73,16 +78,14 @@ export class TriLayout extends Component {
      * Show the content tab.
      * Used by the page-display component.
      */
-    showContent() {
+    showContent(): void {
         this.showTab('content', false);
     }
 
     /**
      * Show the given tab
-     * @param {String} tabName
-     * @param {Boolean }scroll
      */
-    showTab(tabName, scroll = true) {
+    showTab(tabName: string, scroll: boolean = true): void {
         this.scrollCache[this.lastTabShown] = document.documentElement.scrollTop;
 
         // Set tab status
@@ -97,7 +100,7 @@ export class TriLayout extends Component {
 
         // Set the scroll position from cache
         if (scroll) {
-            const pageHeader = document.querySelector('header');
+            const pageHeader = document.querySelector('header') as HTMLElement;
             const defaultScrollTop = pageHeader.getBoundingClientRect().bottom;
             document.documentElement.scrollTop = this.scrollCache[tabName] || defaultScrollTop;
             setTimeout(() => {
@@ -108,4 +111,30 @@ export class TriLayout extends Component {
         this.lastTabShown = tabName;
     }
 
+    setupSidebarScrollHandlers(): void {
+        for (const sidebar of this.sidebarScrollContainers) {
+            sidebar.addEventListener('scroll', () => this.handleSidebarScroll(sidebar), {
+                passive: true,
+            });
+            this.handleSidebarScroll(sidebar);
+        }
+
+        window.addEventListener('resize', () => {
+            for (const sidebar of this.sidebarScrollContainers) {
+                this.handleSidebarScroll(sidebar);
+            }
+        });
+    }
+
+    handleSidebarScroll(sidebar: HTMLElement): void {
+        const scrollable = sidebar.clientHeight !== sidebar.scrollHeight;
+        const atTop = sidebar.scrollTop === 0;
+        const atBottom = (sidebar.scrollTop + sidebar.clientHeight) === sidebar.scrollHeight;
+
+        if (sidebar.parentElement) {
+            sidebar.parentElement.classList.toggle('scroll-away-from-top', !atTop && scrollable);
+            sidebar.parentElement.classList.toggle('scroll-away-from-bottom', !atBottom && scrollable);
+        }
+    }
+
 }
diff --git a/resources/js/components/wysiwyg-input.js b/resources/js/components/wysiwyg-input.js
deleted file mode 100644
index aa21a6371..000000000
--- a/resources/js/components/wysiwyg-input.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import {Component} from './component';
-import {buildForInput} from '../wysiwyg-tinymce/config';
-
-export class WysiwygInput extends Component {
-
-    setup() {
-        this.elem = this.$el;
-
-        const config = buildForInput({
-            language: this.$opts.language,
-            containerElement: this.elem,
-            darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.$opts.textDirection,
-            translations: {},
-            translationMap: window.editor_translations,
-        });
-
-        window.tinymce.init(config).then(editors => {
-            this.editor = editors[0];
-        });
-    }
-
-}
diff --git a/resources/js/components/wysiwyg-input.ts b/resources/js/components/wysiwyg-input.ts
new file mode 100644
index 000000000..1d914adb9
--- /dev/null
+++ b/resources/js/components/wysiwyg-input.ts
@@ -0,0 +1,32 @@
+import {Component} from './component';
+import {el} from "../wysiwyg/utils/dom";
+import {SimpleWysiwygEditorInterface} from "../wysiwyg";
+
+export class WysiwygInput extends Component {
+    private elem!: HTMLTextAreaElement;
+    private wysiwygEditor!: SimpleWysiwygEditorInterface;
+    private textDirection!: string;
+
+    async setup() {
+        this.elem = this.$el as HTMLTextAreaElement;
+        this.textDirection = this.$opts.textDirection;
+
+        type WysiwygModule = typeof import('../wysiwyg');
+        const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
+        const container = el('div', {class: 'basic-editor-container'});
+        this.elem.parentElement?.appendChild(container);
+        this.elem.hidden = true;
+
+        this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, this.elem.value, {
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.textDirection,
+            translations: (window as unknown as Record<string, Object>).editor_translations,
+        });
+
+        this.wysiwygEditor.onChange(() => {
+            this.wysiwygEditor.getContentAsHtml().then(html => {
+                this.elem.value = html;
+            });
+        });
+    }
+}
diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts
index b637c97c1..239f4b924 100644
--- a/resources/js/global.d.ts
+++ b/resources/js/global.d.ts
@@ -15,4 +15,6 @@ declare global {
         baseUrl: (path: string) => string;
         importVersioned: (module: string) => Promise<object>;
     }
-}
\ No newline at end of file
+}
+
+export type CodeModule = (typeof import('./code/index.mjs'));
\ No newline at end of file
diff --git a/resources/js/markdown/actions.js b/resources/js/markdown/actions.ts
similarity index 53%
rename from resources/js/markdown/actions.js
rename to resources/js/markdown/actions.ts
index e99bbf3e1..36d21ab1d 100644
--- a/resources/js/markdown/actions.js
+++ b/resources/js/markdown/actions.ts
@@ -1,20 +1,29 @@
-import * as DrawIO from '../services/drawio.ts';
+import * as DrawIO from '../services/drawio';
+import {MarkdownEditor} from "./index.mjs";
+import {EntitySelectorPopup, ImageManager} from "../components";
+import {MarkdownEditorInputSelection} from "./inputs/interface";
+
+interface ImageManagerImage {
+    id: number;
+    name: string;
+    thumbs: { display: string; };
+    url: string;
+}
 
 export class Actions {
 
-    /**
-     * @param {MarkdownEditor} editor
-     */
-    constructor(editor) {
+    protected readonly editor: MarkdownEditor;
+    protected lastContent: { html: string; markdown: string } = {
+        html: '',
+        markdown: '',
+    };
+
+    constructor(editor: MarkdownEditor) {
         this.editor = editor;
-        this.lastContent = {
-            html: '',
-            markdown: '',
-        };
     }
 
     updateAndRender() {
-        const content = this.#getText();
+        const content = this.editor.input.getText();
         this.editor.config.inputEl.value = content;
 
         const html = this.editor.markdown.render(content);
@@ -30,45 +39,42 @@ export class Actions {
     }
 
     showImageInsert() {
-        /** @type {ImageManager} * */
-        const imageManager = window.$components.first('image-manager');
+        const imageManager = window.$components.first('image-manager') as ImageManager;
 
-        imageManager.show(image => {
+        imageManager.show((image: ImageManagerImage) => {
             const imageUrl = image.thumbs?.display || image.url;
-            const selectedText = this.#getSelectionText();
+            const selectedText = this.editor.input.getSelectionText();
             const newText = `[![${selectedText || image.name}](${imageUrl})](${image.url})`;
             this.#replaceSelection(newText, newText.length);
         }, 'gallery');
     }
 
     insertImage() {
-        const newText = `![${this.#getSelectionText()}](http://)`;
+        const newText = `![${this.editor.input.getSelectionText()}](http://)`;
         this.#replaceSelection(newText, newText.length - 1);
     }
 
     insertLink() {
-        const selectedText = this.#getSelectionText();
+        const selectedText = this.editor.input.getSelectionText();
         const newText = `[${selectedText}]()`;
         const cursorPosDiff = (selectedText === '') ? -3 : -1;
         this.#replaceSelection(newText, newText.length + cursorPosDiff);
     }
 
     showImageManager() {
-        const selectionRange = this.#getSelectionRange();
-        /** @type {ImageManager} * */
-        const imageManager = window.$components.first('image-manager');
-        imageManager.show(image => {
+        const selectionRange = this.editor.input.getSelection();
+        const imageManager = window.$components.first('image-manager') as ImageManager;
+        imageManager.show((image: ImageManagerImage) => {
             this.#insertDrawing(image, selectionRange);
         }, 'drawio');
     }
 
     // Show the popup link selector and insert a link when finished
     showLinkSelector() {
-        const selectionRange = this.#getSelectionRange();
+        const selectionRange = this.editor.input.getSelection();
 
-        /** @type {EntitySelectorPopup} * */
-        const selector = window.$components.first('entity-selector-popup');
-        const selectionText = this.#getSelectionText(selectionRange);
+        const selector = window.$components.first('entity-selector-popup') as EntitySelectorPopup;
+        const selectionText = this.editor.input.getSelectionText(selectionRange);
         selector.show(entity => {
             const selectedText = selectionText || entity.name;
             const newText = `[${selectedText}](${entity.link})`;
@@ -86,7 +92,7 @@ export class Actions {
         const url = this.editor.config.drawioUrl;
         if (!url) return;
 
-        const selectionRange = this.#getSelectionRange();
+        const selectionRange = this.editor.input.getSelection();
 
         DrawIO.show(url, () => Promise.resolve(''), async pngData => {
             const data = {
@@ -96,7 +102,7 @@ export class Actions {
 
             try {
                 const resp = await window.$http.post('/images/drawio', data);
-                this.#insertDrawing(resp.data, selectionRange);
+                this.#insertDrawing(resp.data as ImageManagerImage, selectionRange);
                 DrawIO.close();
             } catch (err) {
                 this.handleDrawingUploadError(err);
@@ -105,20 +111,23 @@ export class Actions {
         });
     }
 
-    #insertDrawing(image, originalSelectionRange) {
+    #insertDrawing(image: ImageManagerImage, originalSelectionRange: MarkdownEditorInputSelection) {
         const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
         this.#replaceSelection(newText, newText.length, originalSelectionRange);
     }
 
     // Show draw.io if enabled and handle save.
-    editDrawing(imgContainer) {
+    editDrawing(imgContainer: HTMLElement) {
         const {drawioUrl} = this.editor.config;
         if (!drawioUrl) {
             return;
         }
 
-        const selectionRange = this.#getSelectionRange();
-        const drawingId = imgContainer.getAttribute('drawio-diagram');
+        const selectionRange = this.editor.input.getSelection();
+        const drawingId = imgContainer.getAttribute('drawio-diagram') || '';
+        if (!drawingId) {
+            return;
+        }
 
         DrawIO.show(drawioUrl, () => DrawIO.load(drawingId), async pngData => {
             const data = {
@@ -128,14 +137,15 @@ export class Actions {
 
             try {
                 const resp = await window.$http.post('/images/drawio', data);
-                const newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
-                const newContent = this.#getText().split('\n').map(line => {
+                const image = resp.data as ImageManagerImage;
+                const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
+                const newContent = this.editor.input.getText().split('\n').map(line => {
                     if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
                         return newText;
                     }
                     return line;
                 }).join('\n');
-                this.#setText(newContent, selectionRange);
+                this.editor.input.setText(newContent, selectionRange);
                 DrawIO.close();
             } catch (err) {
                 this.handleDrawingUploadError(err);
@@ -144,7 +154,7 @@ export class Actions {
         });
     }
 
-    handleDrawingUploadError(error) {
+    handleDrawingUploadError(error: any): void {
         if (error.status === 413) {
             window.$events.emit('error', this.editor.config.text.serverUploadLimit);
         } else {
@@ -162,91 +172,71 @@ export class Actions {
     }
 
     // Scroll to a specified text
-    scrollToText(searchText) {
+    scrollToText(searchText: string): void {
         if (!searchText) {
             return;
         }
 
-        const text = this.editor.cm.state.doc;
-        let lineCount = 1;
-        let scrollToLine = -1;
-        for (const line of text.iterLines()) {
-            if (line.includes(searchText)) {
-                scrollToLine = lineCount;
-                break;
-            }
-            lineCount += 1;
-        }
-
-        if (scrollToLine === -1) {
-            return;
+        const lineRange = this.editor.input.searchForLineContaining(searchText);
+        if (lineRange) {
+            this.editor.input.setSelection(lineRange, true);
+            this.editor.input.focus();
         }
-
-        const line = text.line(scrollToLine);
-        this.#setSelection(line.from, line.to, true);
-        this.focus();
     }
 
     focus() {
-        if (!this.editor.cm.hasFocus) {
-            this.editor.cm.focus();
-        }
+        this.editor.input.focus();
     }
 
     /**
      * Insert content into the editor.
-     * @param {String} content
      */
-    insertContent(content) {
+    insertContent(content: string) {
         this.#replaceSelection(content, content.length);
     }
 
     /**
      * Prepend content to the editor.
-     * @param {String} content
      */
-    prependContent(content) {
+    prependContent(content: string): void {
         content = this.#cleanTextForEditor(content);
-        const selectionRange = this.#getSelectionRange();
+        const selectionRange = this.editor.input.getSelection();
         const selectFrom = selectionRange.from + content.length + 1;
-        this.#dispatchChange(0, 0, `${content}\n`, selectFrom);
-        this.focus();
+        this.editor.input.spliceText(0, 0, `${content}\n`, {from: selectFrom});
+        this.editor.input.focus();
     }
 
     /**
      * Append content to the editor.
-     * @param {String} content
      */
-    appendContent(content) {
+    appendContent(content: string): void {
         content = this.#cleanTextForEditor(content);
-        this.#dispatchChange(this.editor.cm.state.doc.length, `\n${content}`);
-        this.focus();
+        this.editor.input.appendText(content);
+        this.editor.input.focus();
     }
 
     /**
      * Replace the editor's contents
-     * @param {String} content
      */
-    replaceContent(content) {
-        this.#setText(content);
+    replaceContent(content: string): void {
+        this.editor.input.setText(content);
     }
 
     /**
      * Replace the start of the line
      * @param {String} newStart
      */
-    replaceLineStart(newStart) {
-        const selectionRange = this.#getSelectionRange();
-        const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
-
-        const lineContent = line.text;
+    replaceLineStart(newStart: string): void {
+        const selectionRange = this.editor.input.getSelection();
+        const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
+        const lineContent = this.editor.input.getSelectionText(lineRange);
         const lineStart = lineContent.split(' ')[0];
 
         // Remove symbol if already set
         if (lineStart === newStart) {
             const newLineContent = lineContent.replace(`${newStart} `, '');
             const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
-            this.#dispatchChange(line.from, line.to, newLineContent, selectFrom);
+            this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectFrom});
             return;
         }
 
@@ -259,48 +249,46 @@ export class Actions {
         }
 
         const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
-        this.#dispatchChange(line.from, line.to, newLineContent, selectFrom);
+        this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectFrom});
     }
 
     /**
      * Wrap the selection in the given contents start and end contents.
-     * @param {String} start
-     * @param {String} end
      */
-    wrapSelection(start, end) {
-        const selectRange = this.#getSelectionRange();
-        const selectionText = this.#getSelectionText(selectRange);
+    wrapSelection(start: string, end: string): void {
+        const selectRange = this.editor.input.getSelection();
+        const selectionText = this.editor.input.getSelectionText(selectRange);
         if (!selectionText) {
             this.#wrapLine(start, end);
             return;
         }
 
-        let newSelectionText = selectionText;
-        let newRange;
+        let newSelectionText: string;
+        let newRange = {from: selectRange.from, to: selectRange.to};
 
         if (selectionText.startsWith(start) && selectionText.endsWith(end)) {
             newSelectionText = selectionText.slice(start.length, selectionText.length - end.length);
-            newRange = selectRange.extend(selectRange.from, selectRange.to - (start.length + end.length));
+            newRange.to = selectRange.to - (start.length + end.length);
         } else {
             newSelectionText = `${start}${selectionText}${end}`;
-            newRange = selectRange.extend(selectRange.from, selectRange.to + (start.length + end.length));
+            newRange.to = selectRange.to + (start.length + end.length);
         }
 
-        this.#dispatchChange(
+        this.editor.input.spliceText(
             selectRange.from,
             selectRange.to,
             newSelectionText,
-            newRange.anchor,
-            newRange.head,
+            newRange,
         );
     }
 
     replaceLineStartForOrderedList() {
-        const selectionRange = this.#getSelectionRange();
-        const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
-        const prevLine = this.editor.cm.state.doc.line(line.number - 1);
+        const selectionRange = this.editor.input.getSelection();
+        const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
+        const prevLineRange = this.editor.input.getLineRangeFromPosition(lineRange.from - 1);
+        const prevLineText = this.editor.input.getSelectionText(prevLineRange);
 
-        const listMatch = prevLine.text.match(/^(\s*)(\d)([).])\s/) || [];
+        const listMatch = prevLineText.match(/^(\s*)(\d)([).])\s/) || [];
 
         const number = (Number(listMatch[2]) || 0) + 1;
         const whiteSpace = listMatch[1] || '';
@@ -315,45 +303,46 @@ export class Actions {
      * Creates a callout block if none existing, and removes it if cycling past the danger type.
      */
     cycleCalloutTypeAtSelection() {
-        const selectionRange = this.#getSelectionRange();
-        const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
+        const selectionRange = this.editor.input.getSelection();
+        const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
+        const lineText = this.editor.input.getSelectionText(lineRange);
 
         const formats = ['info', 'success', 'warning', 'danger'];
         const joint = formats.join('|');
         const regex = new RegExp(`class="((${joint})\\s+callout|callout\\s+(${joint}))"`, 'i');
-        const matches = regex.exec(line.text);
+        const matches = regex.exec(lineText);
         const format = (matches ? (matches[2] || matches[3]) : '').toLowerCase();
 
         if (format === formats[formats.length - 1]) {
             this.#wrapLine(`<p class="callout ${formats[formats.length - 1]}">`, '</p>');
         } else if (format === '') {
             this.#wrapLine('<p class="callout info">', '</p>');
-        } else {
+        } else if (matches) {
             const newFormatIndex = formats.indexOf(format) + 1;
             const newFormat = formats[newFormatIndex];
-            const newContent = line.text.replace(matches[0], matches[0].replace(format, newFormat));
-            const lineDiff = newContent.length - line.text.length;
-            this.#dispatchChange(
-                line.from,
-                line.to,
+            const newContent = lineText.replace(matches[0], matches[0].replace(format, newFormat));
+            const lineDiff = newContent.length - lineText.length;
+            const anchor = Math.min(selectionRange.from, selectionRange.to);
+            const head = Math.max(selectionRange.from, selectionRange.to);
+            this.editor.input.spliceText(
+                lineRange.from,
+                lineRange.to,
                 newContent,
-                selectionRange.anchor + lineDiff,
-                selectionRange.head + lineDiff,
+                {from: anchor + lineDiff, to: head + lineDiff}
             );
         }
     }
 
-    syncDisplayPosition(event) {
+    syncDisplayPosition(event: Event): void {
         // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
-        const scrollEl = event.target;
+        const scrollEl = event.target as HTMLElement;
         const atEnd = Math.abs(scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop) < 1;
         if (atEnd) {
             this.editor.display.scrollToIndex(-1);
             return;
         }
 
-        const blockInfo = this.editor.cm.lineBlockAtHeight(scrollEl.scrollTop);
-        const range = this.editor.cm.state.sliceDoc(0, blockInfo.from);
+        const range = this.editor.input.getTextAboveView();
         const parser = new DOMParser();
         const doc = parser.parseFromString(this.editor.markdown.render(range), 'text/html');
         const totalLines = doc.documentElement.querySelectorAll('body > *');
@@ -363,54 +352,48 @@ export class Actions {
     /**
      * Fetch and insert the template of the given ID.
      * The page-relative position provided can be used to determine insert location if possible.
-     * @param {String} templateId
-     * @param {Number} posX
-     * @param {Number} posY
      */
-    async insertTemplate(templateId, posX, posY) {
-        const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false);
-        const {data} = await window.$http.get(`/templates/${templateId}`);
-        const content = data.markdown || data.html;
-        this.#dispatchChange(cursorPos, cursorPos, content, cursorPos);
+    async insertTemplate(templateId: string, event: MouseEvent): Promise<void> {
+        const cursorPos = this.editor.input.eventToPosition(event).from;
+        const responseData = (await window.$http.get(`/templates/${templateId}`)).data as {markdown: string, html: string};
+        const content = responseData.markdown || responseData.html;
+        this.editor.input.spliceText(cursorPos, cursorPos, content, {from: cursorPos});
     }
 
     /**
      * Insert multiple images from the clipboard from an event at the provided
      * screen coordinates (Typically form a paste event).
-     * @param {File[]} images
-     * @param {Number} posX
-     * @param {Number} posY
      */
-    insertClipboardImages(images, posX, posY) {
-        const cursorPos = this.editor.cm.posAtCoords({x: posX, y: posY}, false);
+    insertClipboardImages(images: File[], event: MouseEvent): void {
+        const cursorPos = this.editor.input.eventToPosition(event).from;
         for (const image of images) {
             this.uploadImage(image, cursorPos);
         }
     }
 
     /**
-     * Handle image upload and add image into markdown content
-     * @param {File} file
-     * @param {?Number} position
+     * Handle image upload and add image into Markdown content
      */
-    async uploadImage(file, position = null) {
+    async uploadImage(file: File, position: number|null = null): Promise<void> {
         if (file === null || file.type.indexOf('image') !== 0) return;
         let ext = 'png';
 
         if (position === null) {
-            position = this.#getSelectionRange().from;
+            position = this.editor.input.getSelection().from;
         }
 
         if (file.name) {
             const fileNameMatches = file.name.match(/\.(.+)$/);
-            if (fileNameMatches.length > 1) ext = fileNameMatches[1];
+            if (fileNameMatches && fileNameMatches.length > 1) {
+                ext = fileNameMatches[1];
+            }
         }
 
         // Insert image into markdown
         const id = `image-${Math.random().toString(16).slice(2)}`;
         const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
         const placeHolderText = `![](${placeholderImage})`;
-        this.#dispatchChange(position, position, placeHolderText, position);
+        this.editor.input.spliceText(position, position, placeHolderText, {from: position});
 
         const remoteFilename = `image-${Date.now()}.${ext}`;
         const formData = new FormData();
@@ -418,105 +401,53 @@ export class Actions {
         formData.append('uploaded_to', this.editor.config.pageId);
 
         try {
-            const {data} = await window.$http.post('/images/gallery', formData);
-            const newContent = `[![](${data.thumbs.display})](${data.url})`;
+            const image = (await window.$http.post('/images/gallery', formData)).data as ImageManagerImage;
+            const newContent = `[![](${image.thumbs.display})](${image.url})`;
             this.#findAndReplaceContent(placeHolderText, newContent);
-        } catch (err) {
+        } catch (err: any) {
             window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
             this.#findAndReplaceContent(placeHolderText, '');
             console.error(err);
         }
     }
 
-    /**
-     * Get the current text of the editor instance.
-     * @return {string}
-     */
-    #getText() {
-        return this.editor.cm.state.doc.toString();
-    }
-
-    /**
-     * Set the text of the current editor instance.
-     * @param {String} text
-     * @param {?SelectionRange} selectionRange
-     */
-    #setText(text, selectionRange = null) {
-        selectionRange = selectionRange || this.#getSelectionRange();
-        const newDoc = this.editor.cm.state.toText(text);
-        const newSelectFrom = Math.min(selectionRange.from, newDoc.length);
-        const scrollTop = this.editor.cm.scrollDOM.scrollTop;
-        this.#dispatchChange(0, this.editor.cm.state.doc.length, text, newSelectFrom);
-        this.focus();
-        window.requestAnimationFrame(() => {
-            this.editor.cm.scrollDOM.scrollTop = scrollTop;
-        });
-    }
-
     /**
      * Replace the current selection and focus the editor.
      * Takes an offset for the cursor, after the change, relative to the start of the provided string.
      * Can be provided a selection range to use instead of the current selection range.
-     * @param {String} newContent
-     * @param {Number} cursorOffset
-     * @param {?SelectionRange} selectionRange
-     */
-    #replaceSelection(newContent, cursorOffset = 0, selectionRange = null) {
-        selectionRange = selectionRange || this.editor.cm.state.selection.main;
-        const selectFrom = selectionRange.from + cursorOffset;
-        this.#dispatchChange(selectionRange.from, selectionRange.to, newContent, selectFrom);
-        this.focus();
-    }
-
-    /**
-     * Get the text content of the main current selection.
-     * @param {SelectionRange} selectionRange
-     * @return {string}
      */
-    #getSelectionText(selectionRange = null) {
-        selectionRange = selectionRange || this.#getSelectionRange();
-        return this.editor.cm.state.sliceDoc(selectionRange.from, selectionRange.to);
-    }
-
-    /**
-     * Get the range of the current main selection.
-     * @return {SelectionRange}
-     */
-    #getSelectionRange() {
-        return this.editor.cm.state.selection.main;
+    #replaceSelection(newContent: string, offset: number = 0, selection: MarkdownEditorInputSelection|null = null) {
+        selection = selection || this.editor.input.getSelection();
+        const selectFrom = selection.from + offset;
+        this.editor.input.spliceText(selection.from, selection.to, newContent, {from: selectFrom, to: selectFrom});
+        this.editor.input.focus();
     }
 
     /**
      * Cleans the given text to work with the editor.
      * Standardises line endings to what's expected.
-     * @param {String} text
-     * @return {String}
      */
-    #cleanTextForEditor(text) {
+    #cleanTextForEditor(text: string): string {
         return text.replace(/\r\n|\r/g, '\n');
     }
 
     /**
      * Find and replace the first occurrence of [search] with [replace]
-     * @param {String} search
-     * @param {String} replace
      */
-    #findAndReplaceContent(search, replace) {
-        const newText = this.#getText().replace(search, replace);
-        this.#setText(newText);
+    #findAndReplaceContent(search: string, replace: string): void {
+        const newText = this.editor.input.getText().replace(search, replace);
+        this.editor.input.setText(newText);
     }
 
     /**
      * Wrap the line in the given start and end contents.
-     * @param {String} start
-     * @param {String} end
      */
-    #wrapLine(start, end) {
-        const selectionRange = this.#getSelectionRange();
-        const line = this.editor.cm.state.doc.lineAt(selectionRange.from);
-        const lineContent = line.text;
-        let newLineContent;
-        let lineOffset = 0;
+    #wrapLine(start: string, end: string): void {
+        const selectionRange = this.editor.input.getSelection();
+        const lineRange = this.editor.input.getLineRangeFromPosition(selectionRange.from);
+        const lineContent = this.editor.input.getSelectionText(lineRange);
+        let newLineContent: string;
+        let lineOffset: number;
 
         if (lineContent.startsWith(start) && lineContent.endsWith(end)) {
             newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
@@ -526,42 +457,7 @@ export class Actions {
             lineOffset = start.length;
         }
 
-        this.#dispatchChange(line.from, line.to, newLineContent, selectionRange.from + lineOffset);
-    }
-
-    /**
-     * Dispatch changes to the editor.
-     * @param {Number} from
-     * @param {?Number} to
-     * @param {?String} text
-     * @param {?Number} selectFrom
-     * @param {?Number} selectTo
-     */
-    #dispatchChange(from, to = null, text = null, selectFrom = null, selectTo = null) {
-        const tr = {changes: {from, to, insert: text}};
-
-        if (selectFrom) {
-            tr.selection = {anchor: selectFrom};
-            if (selectTo) {
-                tr.selection.head = selectTo;
-            }
-        }
-
-        this.editor.cm.dispatch(tr);
-    }
-
-    /**
-     * Set the current selection range.
-     * Optionally will scroll the new range into view.
-     * @param {Number} from
-     * @param {Number} to
-     * @param {Boolean} scrollIntoView
-     */
-    #setSelection(from, to, scrollIntoView = false) {
-        this.editor.cm.dispatch({
-            selection: {anchor: from, head: to},
-            scrollIntoView,
-        });
+        this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectionRange.from + lineOffset});
     }
 
 }
diff --git a/resources/js/markdown/codemirror.ts b/resources/js/markdown/codemirror.ts
new file mode 100644
index 000000000..1ae018477
--- /dev/null
+++ b/resources/js/markdown/codemirror.ts
@@ -0,0 +1,54 @@
+import {EditorView, KeyBinding, ViewUpdate} from "@codemirror/view";
+import {CodeModule} from "../global";
+import {MarkdownEditorEventMap} from "./dom-handlers";
+import {MarkdownEditorShortcutMap} from "./shortcuts";
+
+/**
+ * Convert editor shortcuts to CodeMirror keybinding format.
+ */
+export function shortcutsToKeyBindings(shortcuts: MarkdownEditorShortcutMap): KeyBinding[] {
+    const keyBindings = [];
+
+    const wrapAction = (action: () => void) => () => {
+        action();
+        return true;
+    };
+
+    for (const [shortcut, action] of Object.entries(shortcuts)) {
+        keyBindings.push({key: shortcut, run: wrapAction(action), preventDefault: true});
+    }
+
+    return keyBindings;
+}
+
+/**
+ * Initiate the codemirror instance for the Markdown editor.
+ */
+export async function init(
+    input: HTMLTextAreaElement,
+    shortcuts: MarkdownEditorShortcutMap,
+    domEventHandlers: MarkdownEditorEventMap,
+    onChange: () => void
+): Promise<EditorView> {
+    const Code = await window.importVersioned('code') as CodeModule;
+
+    function onViewUpdate(v: ViewUpdate) {
+        if (v.docChanged) {
+            onChange();
+        }
+    }
+
+    const cm = Code.markdownEditor(
+        input,
+        onViewUpdate,
+        domEventHandlers,
+        shortcutsToKeyBindings(shortcuts),
+    );
+
+    // Add editor view to the window for easy access/debugging.
+    // Not part of official API/Docs
+    // @ts-ignore
+    window.mdEditorView = cm;
+
+    return cm;
+}
diff --git a/resources/js/markdown/common-events.js b/resources/js/markdown/common-events.js
deleted file mode 100644
index c3d803f70..000000000
--- a/resources/js/markdown/common-events.js
+++ /dev/null
@@ -1,32 +0,0 @@
-function getContentToInsert({html, markdown}) {
-    return markdown || html;
-}
-
-/**
- * @param {MarkdownEditor} editor
- */
-export function listen(editor) {
-    window.$events.listen('editor::replace', eventContent => {
-        const markdown = getContentToInsert(eventContent);
-        editor.actions.replaceContent(markdown);
-    });
-
-    window.$events.listen('editor::append', eventContent => {
-        const markdown = getContentToInsert(eventContent);
-        editor.actions.appendContent(markdown);
-    });
-
-    window.$events.listen('editor::prepend', eventContent => {
-        const markdown = getContentToInsert(eventContent);
-        editor.actions.prependContent(markdown);
-    });
-
-    window.$events.listen('editor::insert', eventContent => {
-        const markdown = getContentToInsert(eventContent);
-        editor.actions.insertContent(markdown);
-    });
-
-    window.$events.listen('editor::focus', () => {
-        editor.actions.focus();
-    });
-}
diff --git a/resources/js/markdown/common-events.ts b/resources/js/markdown/common-events.ts
new file mode 100644
index 000000000..4bfc4bb46
--- /dev/null
+++ b/resources/js/markdown/common-events.ts
@@ -0,0 +1,36 @@
+import {MarkdownEditor} from "./index.mjs";
+
+export interface HtmlOrMarkdown {
+    html: string;
+    markdown: string;
+}
+
+function getContentToInsert({html, markdown}: {html: string, markdown: string}): string {
+    return markdown || html;
+}
+
+export function listenToCommonEvents(editor: MarkdownEditor): void {
+    window.$events.listen('editor::replace', (eventContent: HtmlOrMarkdown) => {
+        const markdown = getContentToInsert(eventContent);
+        editor.actions.replaceContent(markdown);
+    });
+
+    window.$events.listen('editor::append', (eventContent: HtmlOrMarkdown) => {
+        const markdown = getContentToInsert(eventContent);
+        editor.actions.appendContent(markdown);
+    });
+
+    window.$events.listen('editor::prepend', (eventContent: HtmlOrMarkdown) => {
+        const markdown = getContentToInsert(eventContent);
+        editor.actions.prependContent(markdown);
+    });
+
+    window.$events.listen('editor::insert', (eventContent: HtmlOrMarkdown) => {
+        const markdown = getContentToInsert(eventContent);
+        editor.actions.insertContent(markdown);
+    });
+
+    window.$events.listen('editor::focus', () => {
+        editor.actions.focus();
+    });
+}
diff --git a/resources/js/markdown/display.js b/resources/js/markdown/display.ts
similarity index 52%
rename from resources/js/markdown/display.js
rename to resources/js/markdown/display.ts
index 60be26b5f..3eb7e5c6a 100644
--- a/resources/js/markdown/display.js
+++ b/resources/js/markdown/display.ts
@@ -1,35 +1,36 @@
-import {patchDomFromHtmlString} from '../services/vdom.ts';
+import { patchDomFromHtmlString } from '../services/vdom';
+import {MarkdownEditor} from "./index.mjs";
 
 export class Display {
+    protected editor: MarkdownEditor;
+    protected container: HTMLIFrameElement;
+    protected doc: Document | null = null;
+    protected lastDisplayClick: number = 0;
 
-    /**
-     * @param {MarkdownEditor} editor
-     */
-    constructor(editor) {
+    constructor(editor: MarkdownEditor) {
         this.editor = editor;
         this.container = editor.config.displayEl;
 
-        this.doc = null;
-        this.lastDisplayClick = 0;
-
-        if (this.container.contentDocument.readyState === 'complete') {
+        if (this.container.contentDocument?.readyState === 'complete') {
             this.onLoad();
         } else {
             this.container.addEventListener('load', this.onLoad.bind(this));
         }
 
-        this.updateVisibility(editor.settings.get('showPreview'));
-        editor.settings.onChange('showPreview', show => this.updateVisibility(show));
+        this.updateVisibility(Boolean(editor.settings.get('showPreview')));
+        editor.settings.onChange('showPreview', (show) => this.updateVisibility(Boolean(show)));
     }
 
-    updateVisibility(show) {
-        const wrap = this.container.closest('.markdown-editor-wrap');
-        wrap.style.display = show ? null : 'none';
+    protected updateVisibility(show: boolean): void {
+        const wrap = this.container.closest('.markdown-editor-wrap') as HTMLElement;
+        wrap.style.display = show ? '' : 'none';
     }
 
-    onLoad() {
+    protected onLoad(): void {
         this.doc = this.container.contentDocument;
 
+        if (!this.doc) return;
+
         this.loadStylesIntoDisplay();
         this.doc.body.className = 'page-content';
 
@@ -37,20 +38,20 @@ export class Display {
         this.doc.addEventListener('click', this.onDisplayClick.bind(this));
     }
 
-    /**
-     * @param {MouseEvent} event
-     */
-    onDisplayClick(event) {
+    protected onDisplayClick(event: MouseEvent): void {
         const isDblClick = Date.now() - this.lastDisplayClick < 300;
 
-        const link = event.target.closest('a');
+        const link = (event.target as Element).closest('a');
         if (link !== null) {
             event.preventDefault();
-            window.open(link.getAttribute('href'));
+            const href = link.getAttribute('href');
+            if (href) {
+                window.open(href);
+            }
             return;
         }
 
-        const drawing = event.target.closest('[drawio-diagram]');
+        const drawing = (event.target as Element).closest('[drawio-diagram]') as HTMLElement;
         if (drawing !== null && isDblClick) {
             this.editor.actions.editDrawing(drawing);
             return;
@@ -59,10 +60,12 @@ export class Display {
         this.lastDisplayClick = Date.now();
     }
 
-    loadStylesIntoDisplay() {
+    protected loadStylesIntoDisplay(): void {
+        if (!this.doc) return;
+
         this.doc.documentElement.classList.add('markdown-editor-display');
 
-        // Set display to be dark mode if parent is
+        // Set display to be dark mode if the parent is
         if (document.documentElement.classList.contains('dark-mode')) {
             this.doc.documentElement.style.backgroundColor = '#222';
             this.doc.documentElement.classList.add('dark-mode');
@@ -71,24 +74,25 @@ export class Display {
         this.doc.head.innerHTML = '';
         const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
         for (const style of styles) {
-            const copy = style.cloneNode(true);
+            const copy = style.cloneNode(true) as HTMLElement;
             this.doc.head.appendChild(copy);
         }
     }
 
     /**
      * Patch the display DOM with the given HTML content.
-     * @param {String} html
      */
-    patchWithHtml(html) {
-        const {body} = this.doc;
+    public patchWithHtml(html: string): void {
+        if (!this.doc) return;
+
+        const { body } = this.doc;
 
         if (body.children.length === 0) {
             const wrap = document.createElement('div');
             this.doc.body.append(wrap);
         }
 
-        const target = body.children[0];
+        const target = body.children[0] as HTMLElement;
 
         patchDomFromHtmlString(target, html);
     }
@@ -96,14 +100,16 @@ export class Display {
     /**
      * Scroll to the given block index within the display content.
      * Will scroll to the end if the index is -1.
-     * @param {Number} index
      */
-    scrollToIndex(index) {
-        const elems = this.doc.body?.children[0]?.children;
-        if (elems && elems.length <= index) return;
+    public scrollToIndex(index: number): void {
+        const elems = this.doc?.body?.children[0]?.children;
+        if (!elems || elems.length <= index) return;
 
         const topElem = (index === -1) ? elems[elems.length - 1] : elems[index];
-        topElem.scrollIntoView({block: 'start', inline: 'nearest', behavior: 'smooth'});
+        (topElem as Element).scrollIntoView({
+            block: 'start',
+            inline: 'nearest',
+            behavior: 'smooth'
+        });
     }
-
-}
+}
\ No newline at end of file
diff --git a/resources/js/markdown/codemirror.js b/resources/js/markdown/dom-handlers.ts
similarity index 53%
rename from resources/js/markdown/codemirror.js
rename to resources/js/markdown/dom-handlers.ts
index 664767605..37e1723de 100644
--- a/resources/js/markdown/codemirror.js
+++ b/resources/js/markdown/dom-handlers.ts
@@ -1,23 +1,11 @@
-import {provideKeyBindings} from './shortcuts';
-import {debounce} from '../services/util.ts';
-import {Clipboard} from '../services/clipboard.ts';
+import {Clipboard} from "../services/clipboard";
+import {MarkdownEditor} from "./index.mjs";
+import {debounce} from "../services/util";
 
-/**
- * Initiate the codemirror instance for the markdown editor.
- * @param {MarkdownEditor} editor
- * @returns {Promise<void>}
- */
-export async function init(editor) {
-    const Code = await window.importVersioned('code');
 
-    /**
-     * @param {ViewUpdate} v
-     */
-    function onViewUpdate(v) {
-        if (v.docChanged) {
-            editor.actions.updateAndRender();
-        }
-    }
+export type MarkdownEditorEventMap = Record<string, (event: any) => void>;
+
+export function getMarkdownDomEventHandlers(editor: MarkdownEditor): MarkdownEditorEventMap {
 
     const onScrollDebounced = debounce(editor.actions.syncDisplayPosition.bind(editor.actions), 100, false);
     let syncActive = editor.settings.get('scrollSync');
@@ -25,15 +13,19 @@ export async function init(editor) {
         syncActive = val;
     });
 
-    const domEventHandlers = {
+    return {
         // Handle scroll to sync display view
-        scroll: event => syncActive && onScrollDebounced(event),
+        scroll: (event: Event) => syncActive && onScrollDebounced(event),
         // Handle image & content drag n drop
-        drop: event => {
+        drop: (event: DragEvent) => {
+            if (!event.dataTransfer) {
+                return;
+            }
+
             const templateId = event.dataTransfer.getData('bookstack/template');
             if (templateId) {
                 event.preventDefault();
-                editor.actions.insertTemplate(templateId, event.pageX, event.pageY);
+                editor.actions.insertTemplate(templateId, event);
             }
 
             const clipboard = new Clipboard(event.dataTransfer);
@@ -41,16 +33,20 @@ export async function init(editor) {
             if (clipboardImages.length > 0) {
                 event.stopPropagation();
                 event.preventDefault();
-                editor.actions.insertClipboardImages(clipboardImages, event.pageX, event.pageY);
+                editor.actions.insertClipboardImages(clipboardImages, event);
             }
         },
         // Handle dragover event to allow as drop-target in chrome
-        dragover: event => {
+        dragover: (event: DragEvent) => {
             event.preventDefault();
         },
         // Handle image paste
-        paste: event => {
-            const clipboard = new Clipboard(event.clipboardData || event.dataTransfer);
+        paste: (event: ClipboardEvent) => {
+            if (!event.clipboardData) {
+                return;
+            }
+
+            const clipboard = new Clipboard(event.clipboardData);
 
             // Don't handle the event ourselves if no items exist of contains table-looking data
             if (!clipboard.hasItems() || clipboard.containsTabularData()) {
@@ -63,17 +59,4 @@ export async function init(editor) {
             }
         },
     };
-
-    const cm = Code.markdownEditor(
-        editor.config.inputEl,
-        onViewUpdate,
-        domEventHandlers,
-        provideKeyBindings(editor),
-    );
-
-    // Add editor view to window for easy access/debugging.
-    // Not part of official API/Docs
-    window.mdEditorView = cm;
-
-    return cm;
-}
+}
\ No newline at end of file
diff --git a/resources/js/markdown/index.mjs b/resources/js/markdown/index.mjs
deleted file mode 100644
index 46c35c850..000000000
--- a/resources/js/markdown/index.mjs
+++ /dev/null
@@ -1,51 +0,0 @@
-import {Markdown} from './markdown';
-import {Display} from './display';
-import {Actions} from './actions';
-import {Settings} from './settings';
-import {listen} from './common-events';
-import {init as initCodemirror} from './codemirror';
-
-/**
- * Initiate a new markdown editor instance.
- * @param {MarkdownEditorConfig} config
- * @returns {Promise<MarkdownEditor>}
- */
-export async function init(config) {
-    /**
-     * @type {MarkdownEditor}
-     */
-    const editor = {
-        config,
-        markdown: new Markdown(),
-        settings: new Settings(config.settingInputs),
-    };
-
-    editor.actions = new Actions(editor);
-    editor.display = new Display(editor);
-    editor.cm = await initCodemirror(editor);
-
-    listen(editor);
-
-    return editor;
-}
-
-/**
- * @typedef MarkdownEditorConfig
- * @property {String} pageId
- * @property {Element} container
- * @property {Element} displayEl
- * @property {HTMLTextAreaElement} inputEl
- * @property {String} drawioUrl
- * @property {HTMLInputElement[]} settingInputs
- * @property {Object<String, String>} text
- */
-
-/**
- * @typedef MarkdownEditor
- * @property {MarkdownEditorConfig} config
- * @property {Display} display
- * @property {Markdown} markdown
- * @property {Actions} actions
- * @property {EditorView} cm
- * @property {Settings} settings
- */
diff --git a/resources/js/markdown/index.mts b/resources/js/markdown/index.mts
new file mode 100644
index 000000000..0a6e974b7
--- /dev/null
+++ b/resources/js/markdown/index.mts
@@ -0,0 +1,71 @@
+import {Markdown} from './markdown';
+import {Display} from './display';
+import {Actions} from './actions';
+import {Settings} from './settings';
+import {listenToCommonEvents} from './common-events';
+import {init as initCodemirror} from './codemirror';
+import {MarkdownEditorInput} from "./inputs/interface";
+import {CodemirrorInput} from "./inputs/codemirror";
+import {TextareaInput} from "./inputs/textarea";
+import {provideShortcutMap} from "./shortcuts";
+import {getMarkdownDomEventHandlers} from "./dom-handlers";
+
+export interface MarkdownEditorConfig {
+    pageId: string;
+    container: Element;
+    displayEl: HTMLIFrameElement;
+    inputEl: HTMLTextAreaElement;
+    drawioUrl: string;
+    settingInputs: HTMLInputElement[];
+    text: Record<string, string>;
+}
+
+export interface MarkdownEditor {
+    config: MarkdownEditorConfig;
+    display: Display;
+    markdown: Markdown;
+    actions: Actions;
+    input: MarkdownEditorInput;
+    settings: Settings;
+}
+
+/**
+ * Initiate a new Markdown editor instance.
+ */
+export async function init(config: MarkdownEditorConfig): Promise<MarkdownEditor> {
+    const editor: MarkdownEditor = {
+        config,
+        markdown: new Markdown(),
+        settings: new Settings(config.settingInputs),
+    } as MarkdownEditor;
+
+    editor.actions = new Actions(editor);
+    editor.display = new Display(editor);
+
+    const eventHandlers = getMarkdownDomEventHandlers(editor);
+    const shortcuts = provideShortcutMap(editor);
+    const onInputChange = () => editor.actions.updateAndRender();
+
+    const initCodemirrorInput: () => Promise<MarkdownEditorInput> = async () => {
+        const codeMirror = await initCodemirror(config.inputEl, shortcuts, eventHandlers, onInputChange);
+        return new CodemirrorInput(codeMirror);
+    };
+    const initTextAreaInput: () => Promise<MarkdownEditorInput> = async () => {
+        return new TextareaInput(config.inputEl, shortcuts, eventHandlers, onInputChange);
+    };
+
+    const isPlainEditor = Boolean(editor.settings.get('plainEditor'));
+    editor.input = await (isPlainEditor ? initTextAreaInput() : initCodemirrorInput());
+    editor.settings.onChange('plainEditor', async (value) => {
+        const isPlain = Boolean(value);
+        const newInput = await (isPlain ? initTextAreaInput() : initCodemirrorInput());
+        editor.input.teardown();
+        editor.input = newInput;
+    });
+
+    listenToCommonEvents(editor);
+
+    return editor;
+}
+
+
diff --git a/resources/js/markdown/inputs/codemirror.ts b/resources/js/markdown/inputs/codemirror.ts
new file mode 100644
index 000000000..827068238
--- /dev/null
+++ b/resources/js/markdown/inputs/codemirror.ts
@@ -0,0 +1,128 @@
+import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
+import {EditorView} from "@codemirror/view";
+import {ChangeSpec, TransactionSpec} from "@codemirror/state";
+
+
+export class CodemirrorInput implements MarkdownEditorInput {
+    protected cm: EditorView;
+
+    constructor(cm: EditorView) {
+        this.cm = cm;
+    }
+
+    teardown(): void {
+        this.cm.destroy();
+    }
+
+    focus(): void {
+        if (!this.cm.hasFocus) {
+            this.cm.focus();
+        }
+    }
+
+    getSelection(): MarkdownEditorInputSelection {
+        return this.cm.state.selection.main;
+    }
+
+    getSelectionText(selection?: MarkdownEditorInputSelection): string {
+        selection = selection || this.getSelection();
+        return this.cm.state.sliceDoc(selection.from, selection.to);
+    }
+
+    setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean = false) {
+        this.cm.dispatch({
+            selection: {anchor: selection.from, head: selection.to},
+            scrollIntoView,
+        });
+    }
+
+    getText(): string {
+        return this.cm.state.doc.toString();
+    }
+
+    getTextAboveView(): string {
+        const blockInfo = this.cm.lineBlockAtHeight(this.cm.scrollDOM.scrollTop);
+        return this.cm.state.sliceDoc(0, blockInfo.from);
+    }
+
+    setText(text: string, selection?: MarkdownEditorInputSelection) {
+        selection = selection || this.getSelection();
+        const newDoc = this.cm.state.toText(text);
+        const newSelectFrom = Math.min(selection.from, newDoc.length);
+        const scrollTop = this.cm.scrollDOM.scrollTop;
+        this.dispatchChange(0, this.cm.state.doc.length, text, newSelectFrom);
+        this.focus();
+        window.requestAnimationFrame(() => {
+            this.cm.scrollDOM.scrollTop = scrollTop;
+        });
+    }
+
+    spliceText(from: number, to: number, newText: string, selection: Partial<MarkdownEditorInputSelection> | null = null) {
+        const end = (selection?.from === selection?.to) ? null : selection?.to;
+        this.dispatchChange(from, to, newText, selection?.from, end)
+    }
+
+    appendText(text: string) {
+        const end = this.cm.state.doc.length;
+        this.dispatchChange(end, end, `\n${text}`);
+    }
+
+    getLineText(lineIndex: number = -1): string {
+        const index = lineIndex > -1 ? lineIndex : this.getSelection().from;
+        return this.cm.state.doc.lineAt(index).text;
+    }
+
+    eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
+        const cursorPos = this.cm.posAtCoords({x: event.screenX, y: event.screenY}, false);
+        return {from: cursorPos, to: cursorPos};
+    }
+
+    getLineRangeFromPosition(position: number): MarkdownEditorInputSelection {
+        const line = this.cm.state.doc.lineAt(position);
+        return {from: line.from, to: line.to};
+    }
+
+    searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
+        const docText = this.cm.state.doc;
+        let lineCount = 1;
+        let scrollToLine = -1;
+        for (const line of docText.iterLines()) {
+            if (line.includes(text)) {
+                scrollToLine = lineCount;
+                break;
+            }
+            lineCount += 1;
+        }
+
+        if (scrollToLine === -1) {
+            return null;
+        }
+
+        const line = docText.line(scrollToLine);
+        return {from: line.from, to: line.to};
+    }
+
+    /**
+     * Dispatch changes to the editor.
+     */
+    protected dispatchChange(from: number, to: number|null = null, text: string|null = null, selectFrom: number|null = null, selectTo: number|null = null): void {
+        const change: ChangeSpec = {from};
+        if (to) {
+            change.to = to;
+        }
+        if (text) {
+            change.insert = text;
+        }
+        const tr: TransactionSpec = {changes: change};
+
+        if (selectFrom) {
+            tr.selection = {anchor: selectFrom};
+            if (selectTo) {
+                tr.selection.head = selectTo;
+            }
+        }
+
+        this.cm.dispatch(tr);
+    }
+
+}
\ No newline at end of file
diff --git a/resources/js/markdown/inputs/interface.ts b/resources/js/markdown/inputs/interface.ts
new file mode 100644
index 000000000..1f7474a50
--- /dev/null
+++ b/resources/js/markdown/inputs/interface.ts
@@ -0,0 +1,81 @@
+
+export interface MarkdownEditorInputSelection {
+    from: number;
+    to: number;
+}
+
+export interface MarkdownEditorInput {
+    /**
+     * Focus on the editor.
+     */
+    focus(): void;
+
+    /**
+     * Get the current selection range.
+     */
+    getSelection(): MarkdownEditorInputSelection;
+
+    /**
+     * Get the text of the given (or current) selection range.
+     */
+    getSelectionText(selection?: MarkdownEditorInputSelection): string;
+
+    /**
+     * Set the selection range of the editor.
+     */
+    setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void;
+
+    /**
+     * Get the full text of the input.
+     */
+    getText(): string;
+
+    /**
+     * Get just the text which is above (out) the current view range.
+     * This is used for position estimation.
+     */
+    getTextAboveView(): string;
+
+    /**
+     * Set the full text of the input.
+     * Optionally can provide a selection to restore after setting text.
+     */
+    setText(text: string, selection?: MarkdownEditorInputSelection): void;
+
+    /**
+     * Splice in/out text within the input.
+     * Optionally can provide a selection to restore after setting text.
+     */
+    spliceText(from: number, to: number, newText: string, selection: Partial<MarkdownEditorInputSelection>|null): void;
+
+    /**
+     * Append text to the end of the editor.
+     */
+    appendText(text: string): void;
+
+    /**
+     * Get the text of the given line number otherwise the text
+     * of the current selected line.
+     */
+    getLineText(lineIndex:number): string;
+
+    /**
+     * Get a selection representing the line range from the given position.
+     */
+    getLineRangeFromPosition(position: number): MarkdownEditorInputSelection;
+
+    /**
+     * Convert the given event position to a selection position within the input.
+     */
+    eventToPosition(event: MouseEvent): MarkdownEditorInputSelection;
+
+    /**
+     * Search and return a line range which includes the provided text.
+     */
+    searchForLineContaining(text: string): MarkdownEditorInputSelection|null;
+
+    /**
+     * Tear down the input.
+     */
+    teardown(): void;
+}
\ No newline at end of file
diff --git a/resources/js/markdown/inputs/textarea.ts b/resources/js/markdown/inputs/textarea.ts
new file mode 100644
index 000000000..e0c3ac37c
--- /dev/null
+++ b/resources/js/markdown/inputs/textarea.ts
@@ -0,0 +1,315 @@
+import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
+import {MarkdownEditorShortcutMap} from "../shortcuts";
+import {MarkdownEditorEventMap} from "../dom-handlers";
+import {debounce} from "../../services/util";
+
+type UndoStackEntry = {
+    content: string;
+    selection: MarkdownEditorInputSelection;
+}
+
+class UndoStack {
+    protected onChangeDebounced: (callback: () => UndoStackEntry) => void;
+
+    protected stack: UndoStackEntry[] = [];
+    protected pointer: number = -1;
+    protected lastActionTime: number = 0;
+
+    constructor() {
+        this.onChangeDebounced = debounce(this.onChange, 1000, false);
+    }
+
+    undo(): UndoStackEntry|null {
+        if (this.pointer < 1) {
+            return null;
+        }
+
+        this.lastActionTime = Date.now();
+        this.pointer -= 1;
+        return this.stack[this.pointer];
+    }
+
+    redo(): UndoStackEntry|null {
+        const atEnd = this.pointer === this.stack.length - 1;
+        if (atEnd) {
+            return null;
+        }
+
+        this.lastActionTime = Date.now();
+        this.pointer++;
+        return this.stack[this.pointer];
+    }
+
+    push(getValueCallback: () => UndoStackEntry): void {
+        // Ignore changes made via undo/redo actions
+        if (Date.now() - this.lastActionTime < 100) {
+            return;
+        }
+
+        this.onChangeDebounced(getValueCallback);
+    }
+
+    protected onChange(getValueCallback: () => UndoStackEntry) {
+        // Trim the end of the stack from the pointer since we're branching away
+        if (this.pointer !== this.stack.length - 1) {
+            this.stack = this.stack.slice(0, this.pointer)
+        }
+
+        this.stack.push(getValueCallback());
+
+        // Limit stack size
+        if (this.stack.length > 50) {
+            this.stack = this.stack.slice(this.stack.length - 50);
+        }
+
+        this.pointer = this.stack.length - 1;
+    }
+}
+
+export class TextareaInput implements MarkdownEditorInput {
+
+    protected input: HTMLTextAreaElement;
+    protected shortcuts: MarkdownEditorShortcutMap;
+    protected events: MarkdownEditorEventMap;
+    protected onChange: () => void;
+    protected eventController = new AbortController();
+    protected undoStack = new UndoStack();
+
+    protected textSizeCache: {x: number; y: number}|null = null;
+
+    constructor(
+        input: HTMLTextAreaElement,
+        shortcuts: MarkdownEditorShortcutMap,
+        events: MarkdownEditorEventMap,
+        onChange: () => void
+    ) {
+        this.input = input;
+        this.shortcuts = shortcuts;
+        this.events = events;
+        this.onChange = onChange;
+
+        this.onKeyDown = this.onKeyDown.bind(this);
+        this.configureLocalShortcuts();
+        this.configureListeners();
+
+        this.input.style.removeProperty("display");
+        this.undoStack.push(() => ({content: this.getText(), selection: this.getSelection()}));
+    }
+
+    teardown() {
+        this.eventController.abort('teardown');
+    }
+
+    configureLocalShortcuts(): void {
+        this.shortcuts['Mod-z'] = () => {
+            const undoEntry = this.undoStack.undo();
+            if (undoEntry) {
+                this.setText(undoEntry.content);
+                this.setSelection(undoEntry.selection, false);
+            }
+        };
+        this.shortcuts['Mod-y'] = () => {
+            const redoContent = this.undoStack.redo();
+            if (redoContent) {
+                this.setText(redoContent.content);
+                this.setSelection(redoContent.selection, false);
+            }
+        }
+    }
+
+    configureListeners(): void {
+        // Keyboard shortcuts
+        this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
+
+        // Shared event listeners
+        for (const [name, listener] of Object.entries(this.events)) {
+            this.input.addEventListener(name, listener, {signal: this.eventController.signal});
+        }
+
+        // Input change handling
+        this.input.addEventListener('input', () => {
+            this.onChange();
+            this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()}));
+        }, {signal: this.eventController.signal});
+    }
+
+    onKeyDown(e: KeyboardEvent) {
+        const isApple = navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
+        const key = e.key.length > 1 ? e.key : e.key.toLowerCase();
+        const keyParts = [
+            e.shiftKey ? 'Shift' : null,
+            isApple && e.metaKey ? 'Mod' : null,
+            !isApple && e.ctrlKey ? 'Mod' : null,
+            key,
+        ];
+
+        const keyString = keyParts.filter(Boolean).join('-');
+        if (this.shortcuts[keyString]) {
+            e.preventDefault();
+            this.shortcuts[keyString]();
+        }
+    }
+
+    appendText(text: string): void {
+        this.input.value += `\n${text}`;
+        this.input.dispatchEvent(new Event('input'));
+    }
+
+    eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
+        const eventCoords = this.mouseEventToTextRelativeCoords(event);
+        return this.inputPositionToSelection(eventCoords.x, eventCoords.y);
+    }
+
+    focus(): void {
+        this.input.focus();
+    }
+
+    getLineRangeFromPosition(position: number): MarkdownEditorInputSelection {
+        const lines = this.getText().split('\n');
+        let lineStart = 0;
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i];
+            const lineEnd = lineStart + line.length;
+            if (position <= lineEnd) {
+                return {from: lineStart, to: lineEnd};
+            }
+            lineStart = lineEnd + 1;
+        }
+
+        return {from: 0, to: 0};
+    }
+
+    getLineText(lineIndex: number): string {
+        const text = this.getText();
+        const lines = text.split("\n");
+        return lines[lineIndex] || '';
+    }
+
+    getSelection(): MarkdownEditorInputSelection {
+        return {from: this.input.selectionStart, to: this.input.selectionEnd};
+    }
+
+    getSelectionText(selection?: MarkdownEditorInputSelection): string {
+        const text = this.getText();
+        const range = selection || this.getSelection();
+        return text.slice(range.from, range.to);
+    }
+
+    getText(): string {
+        return this.input.value;
+    }
+
+    getTextAboveView(): string {
+        const scrollTop = this.input.scrollTop;
+        const selection = this.inputPositionToSelection(0, scrollTop);
+        return this.getSelectionText({from: 0, to: selection.to});
+    }
+
+    searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
+        const textPosition = this.getText().indexOf(text);
+        if (textPosition > -1) {
+            return this.getLineRangeFromPosition(textPosition);
+        }
+
+        return null;
+    }
+
+    setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void {
+        this.input.selectionStart = selection.from;
+        this.input.selectionEnd = selection.to;
+    }
+
+    setText(text: string, selection?: MarkdownEditorInputSelection): void {
+        this.input.value = text;
+        this.input.dispatchEvent(new Event('input'));
+        if (selection) {
+            this.setSelection(selection, false);
+        }
+    }
+
+    spliceText(from: number, to: number, newText: string, selection: Partial<MarkdownEditorInputSelection> | null): void {
+        const text = this.getText();
+        const updatedText = text.slice(0, from) + newText + text.slice(to);
+        this.setText(updatedText);
+        if (selection && selection.from) {
+            const newSelection = {from: selection.from, to: selection.to || selection.from};
+            this.setSelection(newSelection, false);
+        }
+    }
+
+    protected measureTextSize(): {x: number; y: number} {
+        if (this.textSizeCache) {
+            return this.textSizeCache;
+        }
+
+        const el = document.createElement("div");
+        el.textContent = `a\nb`;
+        const inputStyles = window.getComputedStyle(this.input)
+        el.style.font = inputStyles.font;
+        el.style.lineHeight = inputStyles.lineHeight;
+        el.style.padding = '0px';
+        el.style.display = 'inline-block';
+        el.style.visibility = 'hidden';
+        el.style.position = 'absolute';
+        el.style.whiteSpace = 'pre';
+        this.input.after(el);
+
+        const bounds = el.getBoundingClientRect();
+        el.remove();
+        this.textSizeCache = {
+            x: bounds.width,
+            y: bounds.height / 2,
+        };
+        return this.textSizeCache;
+    }
+
+    protected measureLineCharCount(textWidth: number): number {
+        const inputStyles = window.getComputedStyle(this.input);
+        const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
+        const paddingRight = Number(inputStyles.paddingRight.replace('px', ''));
+        const width = Number(inputStyles.width.replace('px', ''));
+        const textSpace = width - (paddingLeft + paddingRight);
+
+        return Math.floor(textSpace / textWidth);
+    }
+
+    protected mouseEventToTextRelativeCoords(event: MouseEvent): {x: number; y: number} {
+        const inputBounds = this.input.getBoundingClientRect();
+        const inputStyles = window.getComputedStyle(this.input);
+        const paddingTop = Number(inputStyles.paddingTop.replace('px', ''));
+        const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
+
+        const xPos = Math.max(event.clientX - (inputBounds.left + paddingLeft), 0);
+        const yPos = Math.max((event.clientY - (inputBounds.top + paddingTop)) + this.input.scrollTop, 0);
+
+        return {x: xPos, y: yPos};
+    }
+
+    protected inputPositionToSelection(x: number, y: number): MarkdownEditorInputSelection {
+        const textSize = this.measureTextSize();
+        const lineWidth = this.measureLineCharCount(textSize.x);
+
+        const lines = this.getText().split('\n');
+
+        let currY = 0;
+        let currPos = 0;
+        for (const line of lines) {
+            let linePos = 0;
+            const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
+            for (let i = 0; i < wrapCount; i++) {
+                currY += textSize.y;
+                if (currY > y) {
+                    const targetX = Math.floor(x / textSize.x);
+                    const maxPos = Math.min(currPos + linePos + targetX, currPos + line.length);
+                    return {from: maxPos, to: maxPos};
+                }
+
+                linePos += lineWidth;
+            }
+
+            currPos += line.length + 1;
+        }
+
+        return this.getSelection();
+    }
+}
\ No newline at end of file
diff --git a/resources/js/markdown/markdown.js b/resources/js/markdown/markdown.ts
similarity index 68%
rename from resources/js/markdown/markdown.js
rename to resources/js/markdown/markdown.ts
index e63184acc..07ea09e91 100644
--- a/resources/js/markdown/markdown.js
+++ b/resources/js/markdown/markdown.ts
@@ -1,7 +1,9 @@
 import MarkdownIt from 'markdown-it';
+// @ts-ignore
 import mdTasksLists from 'markdown-it-task-lists';
 
 export class Markdown {
+    protected renderer: MarkdownIt;
 
     constructor() {
         this.renderer = new MarkdownIt({html: true});
@@ -9,19 +11,16 @@ export class Markdown {
     }
 
     /**
-     * Get the front-end render used to convert markdown to HTML.
-     * @returns {MarkdownIt}
+     * Get the front-end render used to convert Markdown to HTML.
      */
-    getRenderer() {
+    getRenderer(): MarkdownIt {
         return this.renderer;
     }
 
     /**
      * Convert the given Markdown to HTML.
-     * @param {String} markdown
-     * @returns {String}
      */
-    render(markdown) {
+    render(markdown: string): string {
         return this.renderer.render(markdown);
     }
 
diff --git a/resources/js/markdown/settings.js b/resources/js/markdown/settings.js
deleted file mode 100644
index b843aaa8a..000000000
--- a/resources/js/markdown/settings.js
+++ /dev/null
@@ -1,63 +0,0 @@
-export class Settings {
-
-    constructor(settingInputs) {
-        this.settingMap = {
-            scrollSync: true,
-            showPreview: true,
-            editorWidth: 50,
-        };
-        this.changeListeners = {};
-        this.loadFromLocalStorage();
-        this.applyToInputs(settingInputs);
-        this.listenToInputChanges(settingInputs);
-    }
-
-    applyToInputs(inputs) {
-        for (const input of inputs) {
-            const name = input.getAttribute('name').replace('md-', '');
-            input.checked = this.settingMap[name];
-        }
-    }
-
-    listenToInputChanges(inputs) {
-        for (const input of inputs) {
-            input.addEventListener('change', () => {
-                const name = input.getAttribute('name').replace('md-', '');
-                this.set(name, input.checked);
-            });
-        }
-    }
-
-    loadFromLocalStorage() {
-        const lsValString = window.localStorage.getItem('md-editor-settings');
-        if (!lsValString) {
-            return;
-        }
-
-        const lsVals = JSON.parse(lsValString);
-        for (const [key, value] of Object.entries(lsVals)) {
-            if (value !== null && this.settingMap[key] !== undefined) {
-                this.settingMap[key] = value;
-            }
-        }
-    }
-
-    set(key, value) {
-        this.settingMap[key] = value;
-        window.localStorage.setItem('md-editor-settings', JSON.stringify(this.settingMap));
-        for (const listener of (this.changeListeners[key] || [])) {
-            listener(value);
-        }
-    }
-
-    get(key) {
-        return this.settingMap[key] || null;
-    }
-
-    onChange(key, callback) {
-        const listeners = this.changeListeners[key] || [];
-        listeners.push(callback);
-        this.changeListeners[key] = listeners;
-    }
-
-}
diff --git a/resources/js/markdown/settings.ts b/resources/js/markdown/settings.ts
new file mode 100644
index 000000000..c446cbe05
--- /dev/null
+++ b/resources/js/markdown/settings.ts
@@ -0,0 +1,82 @@
+type ChangeListener = (value: boolean|number) => void;
+
+export class Settings {
+    protected changeListeners: Record<string, ChangeListener[]> = {};
+
+    protected settingMap: Record<string, boolean|number> = {
+        scrollSync: true,
+        showPreview: true,
+        editorWidth: 50,
+        plainEditor: false,
+    };
+
+    constructor(settingInputs: HTMLInputElement[]) {
+        this.loadFromLocalStorage();
+        this.applyToInputs(settingInputs);
+        this.listenToInputChanges(settingInputs);
+    }
+
+    protected applyToInputs(inputs: HTMLInputElement[]): void {
+        for (const input of inputs) {
+            const name = input.getAttribute('name')?.replace('md-', '');
+            if (name && name in this.settingMap) {
+                const value = this.settingMap[name];
+                if (typeof value === 'boolean') {
+                    input.checked = value;
+                } else {
+                    input.value = value.toString();
+                }
+            }
+        }
+    }
+
+    protected listenToInputChanges(inputs: HTMLInputElement[]): void {
+        for (const input of inputs) {
+            input.addEventListener('change', () => {
+                const name = input.getAttribute('name')?.replace('md-', '');
+                if (name && name in this.settingMap) {
+                    let value = (input.type === 'checkbox') ? input.checked : Number(input.value);
+                    this.set(name, value);
+                }
+            });
+        }
+    }
+
+    protected loadFromLocalStorage(): void {
+        const lsValString = window.localStorage.getItem('md-editor-settings');
+        if (!lsValString) {
+            return;
+        }
+
+        try {
+            const lsVals = JSON.parse(lsValString);
+            for (const [key, value] of Object.entries(lsVals)) {
+                if (value !== null && value !== undefined && key in this.settingMap) {
+                    this.settingMap[key] = value as boolean|number;
+                }
+            }
+        } catch (error) {
+            console.warn('Failed to parse settings from localStorage:', error);
+        }
+    }
+
+    public set(key: string, value: boolean|number): void {
+        this.settingMap[key] = value;
+        window.localStorage.setItem('md-editor-settings', JSON.stringify(this.settingMap));
+
+        const listeners = this.changeListeners[key] || [];
+        for (const listener of listeners) {
+            listener(value);
+        }
+    }
+
+    public get(key: string): number|boolean|null {
+        return this.settingMap[key] ?? null;
+    }
+
+    public onChange(key: string, callback: ChangeListener): void {
+        const listeners = this.changeListeners[key] || [];
+        listeners.push(callback);
+        this.changeListeners[key] = listeners;
+    }
+}
\ No newline at end of file
diff --git a/resources/js/markdown/shortcuts.js b/resources/js/markdown/shortcuts.ts
similarity index 69%
rename from resources/js/markdown/shortcuts.js
rename to resources/js/markdown/shortcuts.ts
index 543e6dcdd..175e8f4f0 100644
--- a/resources/js/markdown/shortcuts.js
+++ b/resources/js/markdown/shortcuts.ts
@@ -1,10 +1,12 @@
+import {MarkdownEditor} from "./index.mjs";
+
+export type MarkdownEditorShortcutMap = Record<string, () => void>;
+
 /**
  * Provide shortcuts for the editor instance.
- * @param {MarkdownEditor} editor
- * @returns {Object<String, Function>}
  */
-function provide(editor) {
-    const shortcuts = {};
+export function provideShortcutMap(editor: MarkdownEditor): MarkdownEditorShortcutMap {
+    const shortcuts: MarkdownEditorShortcutMap = {};
 
     // Insert Image shortcut
     shortcuts['Shift-Mod-i'] = () => editor.actions.insertImage();
@@ -39,24 +41,3 @@ function provide(editor) {
 
     return shortcuts;
 }
-
-/**
- * Get the editor shortcuts in CodeMirror keybinding format.
- * @param {MarkdownEditor} editor
- * @return {{key: String, run: function, preventDefault: boolean}[]}
- */
-export function provideKeyBindings(editor) {
-    const shortcuts = provide(editor);
-    const keyBindings = [];
-
-    const wrapAction = action => () => {
-        action();
-        return true;
-    };
-
-    for (const [shortcut, action] of Object.entries(shortcuts)) {
-        keyBindings.push({key: shortcut, run: wrapAction(action), preventDefault: true});
-    }
-
-    return keyBindings;
-}
diff --git a/resources/js/services/components.ts b/resources/js/services/components.ts
index c19939e92..0e13cd0a0 100644
--- a/resources/js/services/components.ts
+++ b/resources/js/services/components.ts
@@ -139,8 +139,8 @@ export class ComponentStore {
     /**
      * Get all the components of the given name.
      */
-    public get(name: string): Component[] {
-        return this.components[name] || [];
+    public get<T extends Component>(name: string): T[] {
+        return (this.components[name] || []) as T[];
     }
 
     /**
@@ -150,4 +150,9 @@ export class ComponentStore {
         const elComponents = this.elementComponentMap.get(element) || {};
         return elComponents[name] || null;
     }
+
+    public allWithinElement<T extends Component>(element: HTMLElement, name: string): T[] {
+        const components = this.get<T>(name);
+        return components.filter(c => element.contains(c.$el));
+    }
 }
diff --git a/resources/js/services/dom.ts b/resources/js/services/dom.ts
index c88827bac..8696fe816 100644
--- a/resources/js/services/dom.ts
+++ b/resources/js/services/dom.ts
@@ -1,3 +1,5 @@
+import {cyrb53} from "./util";
+
 /**
  * Check if the given param is a HTMLElement
  */
@@ -44,9 +46,11 @@ export function forEach(selector: string, callback: (el: Element) => any) {
 /**
  * Helper to listen to multiple DOM events
  */
-export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void {
-    for (const eventName of events) {
-        listenerElement.addEventListener(eventName, callback);
+export function onEvents(listenerElement: Element|null, events: string[], callback: (e: Event) => any): void {
+    if (listenerElement) {
+        for (const eventName of events) {
+            listenerElement.addEventListener(eventName, callback);
+        }
     }
 }
 
@@ -178,3 +182,96 @@ export function htmlToDom(html: string): HTMLElement {
 
     return firstChild;
 }
+
+/**
+ * For the given node and offset, return an adjusted offset that's relative to the given parent element.
+ */
+export function normalizeNodeTextOffsetToParent(node: Node, offset: number, parentElement: HTMLElement): number {
+    if (!parentElement.contains(node)) {
+        throw new Error('ParentElement must be a prent of element');
+    }
+
+    let normalizedOffset = offset;
+    let currentNode: Node|null = node.nodeType === Node.TEXT_NODE ?
+        node : node.childNodes[offset];
+
+    while (currentNode !== parentElement && currentNode) {
+        if (currentNode.previousSibling) {
+            currentNode = currentNode.previousSibling;
+            normalizedOffset += (currentNode.textContent?.length || 0);
+        } else {
+            currentNode = currentNode.parentNode;
+        }
+    }
+
+    return normalizedOffset;
+}
+
+/**
+ * Find the target child node and adjusted offset based on a parent node and text offset.
+ * Returns null if offset not found within the given parent node.
+ */
+export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number): ({node: Node, offset: number}|null) {
+    if (offset === 0) {
+        return { node: parentNode, offset: 0 };
+    }
+
+    let currentOffset = 0;
+    let currentNode = null;
+
+    for (let i = 0; i < parentNode.childNodes.length; i++) {
+        currentNode = parentNode.childNodes[i];
+
+        if (currentNode.nodeType === Node.TEXT_NODE) {
+            // For text nodes, count the length of their content
+            // Returns if within range
+            const textLength = (currentNode.textContent || '').length;
+            if (currentOffset + textLength >= offset) {
+                return {
+                    node: currentNode,
+                    offset: offset - currentOffset
+                };
+            }
+
+            currentOffset += textLength;
+        } else if (currentNode.nodeType === Node.ELEMENT_NODE) {
+            // Otherwise, if an element, track the text length and search within
+            // if in range for the target offset
+            const elementTextLength = (currentNode.textContent || '').length;
+            if (currentOffset + elementTextLength >= offset) {
+                return findTargetNodeAndOffset(currentNode as HTMLElement, offset - currentOffset);
+            }
+
+            currentOffset += elementTextLength;
+        }
+    }
+
+    // Return null if not found within range
+    return null;
+}
+
+/**
+ * Create a hash for the given HTML element content.
+ */
+export function hashElement(element: HTMLElement): string {
+    const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, '');
+    return cyrb53(normalisedElemText);
+}
+
+/**
+ * Find the closest scroll container parent for the given element
+ * otherwise will default to the body element.
+ */
+export function findClosestScrollContainer(start: HTMLElement): HTMLElement {
+    let el: HTMLElement|null = start;
+    do {
+        const computed = window.getComputedStyle(el);
+        if (computed.overflowY === 'scroll') {
+            return el;
+        }
+
+        el = el.parentElement;
+    } while (el);
+
+    return document.body;
+}
\ No newline at end of file
diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts
index be9fba7ec..6045d51f8 100644
--- a/resources/js/services/events.ts
+++ b/resources/js/services/events.ts
@@ -1,7 +1,9 @@
 import {HttpError} from "./http";
 
+type Listener = (data: any) => void;
+
 export class EventManager {
-    protected listeners: Record<string, ((data: any) => void)[]> = {};
+    protected listeners: Record<string, Listener[]> = {};
     protected stack: {name: string, data: {}}[] = [];
 
     /**
@@ -24,6 +26,17 @@ export class EventManager {
         this.listeners[eventName].push(callback);
     }
 
+    /**
+     * Remove an event listener which is using the given callback for the given event name.
+     */
+    remove(eventName: string, callback: Listener): void {
+        const listeners = this.listeners[eventName] || [];
+        const index = listeners.indexOf(callback);
+        if (index !== -1) {
+            listeners.splice(index, 1);
+        }
+    }
+
     /**
      * Emit an event for public use.
      * Sends the event via the native DOM event handling system.
@@ -53,8 +66,7 @@ export class EventManager {
     /**
      * Notify of standard server-provided validation errors.
      */
-    showValidationErrors(responseErr: {status?: number, data?: object}): void {
-        if (!responseErr.status) return;
+    showValidationErrors(responseErr: HttpError): void {
         if (responseErr.status === 422 && responseErr.data) {
             const message = Object.values(responseErr.data).flat().join('\n');
             this.error(message);
diff --git a/resources/js/services/translations.ts b/resources/js/services/translations.ts
index b37dbdfb0..f548a51d1 100644
--- a/resources/js/services/translations.ts
+++ b/resources/js/services/translations.ts
@@ -10,6 +10,7 @@ export class Translator {
      * to use. Similar format at Laravel's 'trans_choice' helper.
      */
     choice(translation: string, count: number, replacements: Record<string, string> = {}): string {
+        replacements = Object.assign({}, {count: String(count)}, replacements);
         const splitText = translation.split('|');
         const exactCountRegex = /^{([0-9]+)}/;
         const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts
index c5a5d2db8..74ae1a935 100644
--- a/resources/js/services/util.ts
+++ b/resources/js/services/util.ts
@@ -5,7 +5,7 @@
  * leading edge, instead of the trailing.
  * @attribution https://davidwalsh.name/javascript-debounce-function
  */
-export function debounce(func: Function, waitMs: number, immediate: boolean): Function {
+export function debounce<T extends (...args: any[]) => any>(func: T, waitMs: number, immediate: boolean): T {
     let timeout: number|null = null;
     return function debouncedWrapper(this: any, ...args: any[]) {
         const context: any = this;
@@ -19,7 +19,7 @@ export function debounce(func: Function, waitMs: number, immediate: boolean): Fu
         }
         timeout = window.setTimeout(later, waitMs);
         if (callNow) func.apply(context, args);
-    };
+    } as T;
 }
 
 function isDetailsElement(element: HTMLElement): element is HTMLDetailsElement {
@@ -144,4 +144,25 @@ function getVersion(): string {
 export function importVersioned(moduleName: string): Promise<object> {
     const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`);
     return import(importPath);
+}
+
+/*
+    cyrb53 (c) 2018 bryc (github.com/bryc)
+    License: Public domain (or MIT if needed). Attribution appreciated.
+    A fast and simple 53-bit string hash function with decent collision resistance.
+    Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity.
+    Taken from: https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
+*/
+export function cyrb53(str: string, seed: number = 0): string {
+    let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
+    for(let i = 0, ch; i < str.length; i++) {
+        ch = str.charCodeAt(i);
+        h1 = Math.imul(h1 ^ ch, 2654435761);
+        h2 = Math.imul(h2 ^ ch, 1597334677);
+    }
+    h1  = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
+    h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
+    h2  = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
+    h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
+    return String((4294967296 * (2097151 & h2) + (h1 >>> 0)));
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg-tinymce/config.js b/resources/js/wysiwyg-tinymce/config.js
index 1666aa500..c0cfd37d9 100644
--- a/resources/js/wysiwyg-tinymce/config.js
+++ b/resources/js/wysiwyg-tinymce/config.js
@@ -310,54 +310,6 @@ export function buildForEditor(options) {
     };
 }
 
-/**
- * @param {WysiwygConfigOptions} options
- * @return {RawEditorOptions}
- */
-export function buildForInput(options) {
-    // Set language
-    window.tinymce.addI18n(options.language, options.translationMap);
-
-    // BookStack Version
-    const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1];
-
-    // Return config object
-    return {
-        width: '100%',
-        height: '185px',
-        target: options.containerElement,
-        cache_suffix: `?version=${version}`,
-        content_css: [
-            window.baseUrl('/dist/styles.css'),
-        ],
-        branding: false,
-        skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
-        body_class: 'wysiwyg-input',
-        browser_spellcheck: true,
-        relative_urls: false,
-        language: options.language,
-        directionality: options.textDirection,
-        remove_script_host: false,
-        document_base_url: window.baseUrl('/'),
-        end_container_on_empty_block: true,
-        remove_trailing_brs: false,
-        statusbar: false,
-        menubar: false,
-        plugins: 'link autolink lists',
-        contextmenu: false,
-        toolbar: 'bold italic link bullist numlist',
-        content_style: getContentStyle(options),
-        file_picker_types: 'file',
-        valid_elements: 'p,a[href|title|target],ol,ul,li,strong,em,br',
-        file_picker_callback: filePickerCallback,
-        init_instance_callback(editor) {
-            addCustomHeadContent(editor.getDoc());
-
-            editor.contentDocument.documentElement.classList.toggle('dark-mode', options.darkMode);
-        },
-    };
-}
-
 /**
  * @typedef {Object} WysiwygConfigOptions
  * @property {Element} containerElement
diff --git a/resources/js/wysiwyg/api/api.ts b/resources/js/wysiwyg/api/api.ts
new file mode 100644
index 000000000..f732170b3
--- /dev/null
+++ b/resources/js/wysiwyg/api/api.ts
@@ -0,0 +1,14 @@
+import {EditorApiUiModule} from "./ui";
+import {EditorUiContext} from "../ui/framework/core";
+import {EditorApiContentModule} from "./content";
+
+export class EditorApi {
+
+    public ui: EditorApiUiModule;
+    public content: EditorApiContentModule;
+
+    constructor(context: EditorUiContext) {
+        this.ui = new EditorApiUiModule(context);
+        this.content = new EditorApiContentModule(context);
+    }
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/api/content.ts b/resources/js/wysiwyg/api/content.ts
new file mode 100644
index 000000000..167ec7905
--- /dev/null
+++ b/resources/js/wysiwyg/api/content.ts
@@ -0,0 +1,26 @@
+import {EditorUiContext} from "../ui/framework/core";
+import {appendHtmlToEditor, insertHtmlIntoEditor, prependHtmlToEditor} from "../utils/actions";
+
+
+export class EditorApiContentModule {
+    readonly #context: EditorUiContext;
+
+    constructor(context: EditorUiContext) {
+        this.#context = context;
+    }
+
+    insertHtml(html: string, position: string = 'selection'): void {
+        const validPositions = ['start', 'end', 'selection'];
+        if (!validPositions.includes(position)) {
+            throw new Error(`Invalid position: ${position}. Valid positions are: ${validPositions.join(', ')}`);
+        }
+
+        if (position === 'start') {
+            prependHtmlToEditor(this.#context.editor, html);
+        } else if (position === 'end') {
+            appendHtmlToEditor(this.#context.editor, html);
+        } else {
+            insertHtmlIntoEditor(this.#context.editor, html);
+        }
+    }
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/api/ui.ts b/resources/js/wysiwyg/api/ui.ts
new file mode 100644
index 000000000..c5b822a46
--- /dev/null
+++ b/resources/js/wysiwyg/api/ui.ts
@@ -0,0 +1,89 @@
+import {EditorButton} from "../ui/framework/buttons";
+import {EditorContainerUiElement, EditorUiContext} from "../ui/framework/core";
+import {EditorOverflowContainer} from "../ui/framework/blocks/overflow-container";
+
+type EditorApiButtonOptions = {
+    label?: string;
+    icon?: string;
+    action: () => void;
+};
+
+export class EditorApiButton {
+    readonly #button: EditorButton;
+    #isActive: boolean = false;
+
+    constructor(options: EditorApiButtonOptions, context: EditorUiContext) {
+        this.#button = new EditorButton({
+            label: options.label || '',
+            icon: options.icon || '',
+            action: () => {
+                options.action();
+            },
+            isActive: () => this.#isActive,
+        });
+        this.#button.setContext(context);
+    }
+
+    setActive(active: boolean = true): void {
+        this.#isActive = active;
+        this.#button.setActiveState(active);
+    }
+
+    _getOriginalModel() {
+        return this.#button;
+    }
+}
+
+export class EditorApiToolbar {
+    readonly #toolbar: EditorContainerUiElement;
+
+    constructor(toolbar: EditorContainerUiElement) {
+        this.#toolbar = toolbar;
+    }
+
+    getSections(): EditorApiToolbarSection[] {
+        const sections = this.#toolbar.getChildren();
+        return sections.filter(section => {
+            return section instanceof EditorOverflowContainer;
+        }).map(section => new EditorApiToolbarSection(section));
+    }
+}
+
+export class EditorApiToolbarSection {
+    readonly #section: EditorOverflowContainer;
+
+    constructor(section: EditorOverflowContainer) {
+        this.#section = section;
+    }
+
+    getLabel(): string {
+        return this.#section.getLabel();
+    }
+
+    addButton(button: EditorApiButton, targetIndex: number = -1): void {
+        this.#section.addChild(button._getOriginalModel(), targetIndex);
+        this.#section.rebuildDOM();
+    }
+}
+
+
+export class EditorApiUiModule {
+    readonly #context: EditorUiContext;
+    
+    constructor(context: EditorUiContext) {
+        this.#context = context;
+    }
+    
+    createButton(options: EditorApiButtonOptions): EditorApiButton {
+        return new EditorApiButton(options, this.#context);
+    }
+
+    getMainToolbar(): EditorApiToolbar|null {
+        const toolbar = this.#context.manager.getToolbar();
+        if (!toolbar) {
+            return null;
+        }
+
+        return new EditorApiToolbar(toolbar);
+    }
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts
index ffdc7d7e8..5d1762ff8 100644
--- a/resources/js/wysiwyg/index.ts
+++ b/resources/js/wysiwyg/index.ts
@@ -1,75 +1,81 @@
-import {$getSelection, createEditor, CreateEditorArgs, LexicalEditor} from 'lexical';
+import {createEditor} from 'lexical';
 import {createEmptyHistoryState, registerHistory} from '@lexical/history';
 import {registerRichText} from '@lexical/rich-text';
 import {mergeRegister} from '@lexical/utils';
-import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
+import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
 import {buildEditorUI} from "./ui";
-import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
+import {focusEditor, getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
 import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
 import {EditorUiContext} from "./ui/framework/core";
 import {listen as listenToCommonEvents} from "./services/common-events";
 import {registerDropPasteHandling} from "./services/drop-paste-handling";
 import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler";
 import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler";
-import {el} from "./utils/dom";
 import {registerShortcuts} from "./services/shortcuts";
 import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
 import {registerKeyboardHandling} from "./services/keyboard-handling";
 import {registerAutoLinks} from "./services/auto-links";
+import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
+import {modals} from "./ui/defaults/modals";
+import {CodeBlockDecorator} from "./ui/decorators/code-block";
+import {DiagramDecorator} from "./ui/decorators/diagram";
+import {registerMouseHandling} from "./services/mouse-handling";
+import {registerSelectionHandling} from "./services/selection-handling";
+import {EditorApi} from "./api/api";
+
+const theme = {
+    text: {
+        bold: 'editor-theme-bold',
+        code: 'editor-theme-code',
+        italic: 'editor-theme-italic',
+        strikethrough: 'editor-theme-strikethrough',
+        subscript: 'editor-theme-subscript',
+        superscript: 'editor-theme-superscript',
+        underline: 'editor-theme-underline',
+        underlineStrikethrough: 'editor-theme-underline-strikethrough',
+    }
+};
 
 export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
-    const config: CreateEditorArgs = {
+    const editor = createEditor({
         namespace: 'BookStackPageEditor',
         nodes: getNodesForPageEditor(),
         onError: console.error,
-        theme: {
-            text: {
-                bold: 'editor-theme-bold',
-                code: 'editor-theme-code',
-                italic: 'editor-theme-italic',
-                strikethrough: 'editor-theme-strikethrough',
-                subscript: 'editor-theme-subscript',
-                superscript: 'editor-theme-superscript',
-                underline: 'editor-theme-underline',
-                underlineStrikethrough: 'editor-theme-underline-strikethrough',
-            }
-        }
-    };
-
-    const editArea = el('div', {
-        contenteditable: 'true',
-        class: 'editor-content-area page-content',
+        theme: theme,
     });
-    const editWrap = el('div', {
-        class: 'editor-content-wrap',
-    }, [editArea]);
-
-    container.append(editWrap);
-    container.classList.add('editor-container');
-    container.setAttribute('dir', options.textDirection);
-    if (options.darkMode) {
-        container.classList.add('editor-dark');
-    }
-
-    const editor = createEditor(config);
-    editor.setRootElement(editArea);
-    const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options);
+    const context: EditorUiContext = buildEditorUI(container, editor, {
+        ...options,
+        editorClass: 'page-content',
+    });
+    editor.setRootElement(context.editorDOM);
 
     mergeRegister(
         registerRichText(editor),
         registerHistory(editor, createEmptyHistoryState(), 300),
         registerShortcuts(context),
         registerKeyboardHandling(context),
-        registerTableResizer(editor, editWrap),
+        registerMouseHandling(context),
+        registerSelectionHandling(context),
+        registerTableResizer(editor, context.scrollDOM),
         registerTableSelectionHandler(editor),
-        registerTaskListHandler(editor, editArea),
+        registerTaskListHandler(editor, context.editorDOM),
         registerDropPasteHandling(context),
         registerNodeResizer(context),
         registerAutoLinks(editor),
     );
 
-    listenToCommonEvents(editor);
+    // Register toolbars, modals & decorators
+    context.manager.setToolbar(getMainEditorFullToolbar(context));
+    for (const key of Object.keys(contextToolbars)) {
+        context.manager.registerContextToolbar(key, contextToolbars[key]);
+    }
+    for (const key of Object.keys(modals)) {
+        context.manager.registerModal(key, modals[key]);
+    }
+    context.manager.registerDecoratorType('code', CodeBlockDecorator);
+    context.manager.registerDecoratorType('diagram', DiagramDecorator);
 
+    listenToCommonEvents(editor);
     setEditorContentFromHtml(editor, htmlContent);
 
     const debugView = document.getElementById('lexical-debug');
@@ -84,22 +90,91 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
 
     // @ts-ignore
     window.debugEditorState = () => {
-        console.log(editor.getEditorState().toJSON());
+        return editor.getEditorState().toJSON();
     };
 
     registerCommonNodeMutationListeners(context);
 
-    return new SimpleWysiwygEditorInterface(editor);
+    window.$events.emitPublic(container, 'editor-wysiwyg::post-init', {
+        usage: 'page-editor',
+        api: new EditorApi(context),
+    });
+
+    return new SimpleWysiwygEditorInterface(context);
+}
+
+export function createBasicEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
+    const editor = createEditor({
+        namespace: 'BookStackBasicEditor',
+        nodes: getNodesForBasicEditor(),
+        onError: console.error,
+        theme: theme,
+    });
+    const context: EditorUiContext = buildEditorUI(container, editor, options);
+    editor.setRootElement(context.editorDOM);
+
+    const editorTeardown = mergeRegister(
+        registerRichText(editor),
+        registerHistory(editor, createEmptyHistoryState(), 300),
+        registerShortcuts(context),
+        registerAutoLinks(editor),
+    );
+
+    // Register toolbars, modals & decorators
+    context.manager.setToolbar(getBasicEditorToolbar(context));
+    context.manager.registerContextToolbar('link', contextToolbars.link);
+    context.manager.registerModal('link', modals.link);
+    context.manager.onTeardown(editorTeardown);
+
+    setEditorContentFromHtml(editor, htmlContent);
+
+    window.$events.emitPublic(container, 'editor-wysiwyg::post-init', {
+        usage: 'description-editor',
+        api: new EditorApi(context),
+    });
+
+    return new SimpleWysiwygEditorInterface(context);
 }
 
 export class SimpleWysiwygEditorInterface {
-    protected editor: LexicalEditor;
+    protected context: EditorUiContext;
+    protected onChangeListeners: (() => void)[] = [];
+    protected editorListenerTeardown: (() => void)|null = null;
 
-    constructor(editor: LexicalEditor) {
-        this.editor = editor;
+    constructor(context: EditorUiContext) {
+        this.context = context;
     }
 
     async getContentAsHtml(): Promise<string> {
-        return await getEditorContentAsHtml(this.editor);
+        return await getEditorContentAsHtml(this.context.editor);
+    }
+
+    onChange(listener: () => void) {
+        this.onChangeListeners.push(listener);
+        this.startListeningToChanges();
+    }
+
+    focus(): void {
+        focusEditor(this.context.editor);
+    }
+
+    remove() {
+        this.context.manager.teardown();
+        this.context.containerDOM.remove();
+        if (this.editorListenerTeardown) {
+            this.editorListenerTeardown();
+        }
+    }
+
+    protected startListeningToChanges(): void {
+        if (this.editorListenerTeardown) {
+            return;
+        }
+
+        this.editorListenerTeardown = this.context.editor.registerUpdateListener(() => {
+             for (const listener of this.onChangeListeners) {
+                 listener();
+             }
+        });
     }
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts
index c70a906a0..26cf25a80 100644
--- a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts
+++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts
@@ -355,6 +355,7 @@ function onSelectionChange(
               lastNode instanceof ParagraphNode &&
               lastNode.getChildrenSize() === 0
             ) {
+              selection.format = lastNode.getTextFormat();
               selection.style = lastNode.getTextStyle();
             } else {
               selection.format = 0;
diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts
index 7306e6bca..95a723469 100644
--- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts
+++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts
@@ -175,7 +175,7 @@ export type NodeKey = string;
 
 export class LexicalNode {
   // Allow us to look up the type including static props
-  ['constructor']!: KlassConstructor<typeof LexicalNode>;
+  declare ['constructor']: KlassConstructor<typeof LexicalNode>;
   /** @internal */
   __type: string;
   /** @internal */
@@ -383,6 +383,14 @@ export class LexicalNode {
     return isSelected;
   }
 
+    /**
+     * Indicate if this node should be selected directly instead of the default
+     * where the selection would descend to the nearest initial child element.
+     */
+  shouldSelectDirectly(): boolean {
+      return false;
+  }
+
   /**
    * Returns this nodes key.
    */
diff --git a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts
index db18cfc4a..36e2db547 100644
--- a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts
+++ b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts
@@ -17,7 +17,7 @@ import invariant from 'lexical/shared/invariant';
 import {
   $createLineBreakNode,
   $createParagraphNode,
-  $createTextNode,
+  $createTextNode, $getNearestNodeFromDOMNode,
   $isDecoratorNode,
   $isElementNode,
   $isLineBreakNode,
@@ -63,6 +63,7 @@ import {
   toggleTextFormatType,
 } from './LexicalUtils';
 import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
+import {$selectSingleNode} from "../../utils/selection";
 
 export type TextPointType = {
   _selection: BaseSelection;
@@ -475,12 +476,12 @@ export class RangeSelection implements BaseSelection {
     const startOffset = firstPoint.offset;
     const endOffset = lastPoint.offset;
 
-    if ($isElementNode(firstNode)) {
+    if ($isElementNode(firstNode) && !firstNode.shouldSelectDirectly()) {
       const firstNodeDescendant =
         firstNode.getDescendantByIndex<ElementNode>(startOffset);
       firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
     }
-    if ($isElementNode(lastNode)) {
+    if ($isElementNode(lastNode) && !lastNode.shouldSelectDirectly()) {
       let lastNodeDescendant =
         lastNode.getDescendantByIndex<ElementNode>(endOffset);
       // We don't want to over-select, as node selection infers the child before
@@ -498,7 +499,7 @@ export class RangeSelection implements BaseSelection {
     let nodes: Array<LexicalNode>;
 
     if (firstNode.is(lastNode)) {
-      if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
+      if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0 && !firstNode.shouldSelectDirectly()) {
         nodes = [];
       } else {
         nodes = [firstNode];
@@ -2568,6 +2569,17 @@ export function updateDOMSelection(
   }
 
   if (!$isRangeSelection(nextSelection)) {
+
+    // If the DOM selection enters a decorator node update the selection to a single node selection
+    if (activeElement !== null && domSelection.isCollapsed && focusDOMNode instanceof Node) {
+      const node = $getNearestNodeFromDOMNode(focusDOMNode);
+      if ($isDecoratorNode(node)) {
+        domSelection.removeAllRanges();
+        $selectSingleNode(node);
+        return;
+      }
+    }
+
     // We don't remove selection if the prevSelection is null because
     // of editor.setRootElement(). If this occurs on init when the
     // editor is already focused, then this can cause the editor to
@@ -2696,6 +2708,8 @@ export function updateDOMSelection(
         const range = document.createRange();
         range.selectNode(selectionTarget);
         selectionRect = range.getBoundingClientRect();
+      } else if (selectionTarget instanceof Range) {
+          selectionRect = selectionTarget.getBoundingClientRect();
       } else {
         selectionRect = selectionTarget.getBoundingClientRect();
       }
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts
index 99d2669d9..5015f593e 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts
@@ -24,7 +24,7 @@ export interface DecoratorNode<T> {
 /** @noInheritDoc */
 // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
 export class DecoratorNode<T> extends LexicalNode {
-  ['constructor']!: KlassConstructor<typeof DecoratorNode<T>>;
+  declare ['constructor']: KlassConstructor<typeof DecoratorNode<T>>;
   constructor(key?: NodeKey) {
     super(key);
   }
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts
index 9ad508411..0329aed6f 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts
@@ -55,7 +55,7 @@ export interface ElementNode {
 /** @noInheritDoc */
 // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
 export class ElementNode extends LexicalNode {
-  ['constructor']!: KlassConstructor<typeof ElementNode>;
+  declare ['constructor']: KlassConstructor<typeof ElementNode>;
   /** @internal */
   __first: null | NodeKey;
   /** @internal */
@@ -150,6 +150,20 @@ export class ElementNode extends LexicalNode {
     }
     return node;
   }
+    getFirstSelectableDescendant<T extends LexicalNode>(): null | T {
+      if (this.shouldSelectDirectly()) {
+          return null;
+      }
+        let node = this.getFirstChild<T>();
+        while ($isElementNode(node) && !node.shouldSelectDirectly()) {
+            const child = node.getFirstChild<T>();
+            if (child === null) {
+                break;
+            }
+            node = child;
+        }
+        return node;
+    }
   getLastDescendant<T extends LexicalNode>(): null | T {
     let node = this.getLastChild<T>();
     while ($isElementNode(node)) {
@@ -161,6 +175,20 @@ export class ElementNode extends LexicalNode {
     }
     return node;
   }
+    getLastSelectableDescendant<T extends LexicalNode>(): null | T {
+      if (this.shouldSelectDirectly()) {
+          return null;
+      }
+        let node = this.getLastChild<T>();
+        while ($isElementNode(node) && !node.shouldSelectDirectly()) {
+            const child = node.getLastChild<T>();
+            if (child === null) {
+                break;
+            }
+            node = child;
+        }
+        return node;
+    }
   getDescendantByIndex<T extends LexicalNode>(index: number): null | T {
     const children = this.getChildren<T>();
     const childrenLength = children.length;
@@ -279,7 +307,7 @@ export class ElementNode extends LexicalNode {
     let anchorOffset = _anchorOffset;
     let focusOffset = _focusOffset;
     const childrenCount = this.getChildrenSize();
-    if (!this.canBeEmpty()) {
+    if (!this.canBeEmpty() && !this.shouldSelectDirectly()) {
       if (_anchorOffset === 0 && _focusOffset === 0) {
         const firstChild = this.getFirstChild();
         if ($isTextNode(firstChild) || $isElementNode(firstChild)) {
@@ -319,11 +347,11 @@ export class ElementNode extends LexicalNode {
     return selection;
   }
   selectStart(): RangeSelection {
-    const firstNode = this.getFirstDescendant();
+    const firstNode = this.getFirstSelectableDescendant();
     return firstNode ? firstNode.selectStart() : this.select();
   }
   selectEnd(): RangeSelection {
-    const lastNode = this.getLastDescendant();
+    const lastNode = this.getLastSelectableDescendant();
     return lastNode ? lastNode.selectEnd() : this.select();
   }
   clear(): this {
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts
index 2d28db08c..b1746e7f8 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts
@@ -22,7 +22,7 @@ export type SerializedLineBreakNode = SerializedLexicalNode;
 
 /** @noInheritDoc */
 export class LineBreakNode extends LexicalNode {
-  ['constructor']!: KlassConstructor<typeof LineBreakNode>;
+  declare ['constructor']: KlassConstructor<typeof LineBreakNode>;
   static getType(): string {
     return 'linebreak';
   }
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts
index f6f57c91c..6711936da 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts
@@ -19,7 +19,7 @@ import type {
   LexicalNode,
   NodeKey,
 } from '../LexicalNode';
-import type {RangeSelection} from 'lexical';
+import {RangeSelection, TEXT_TYPE_TO_FORMAT, TextFormatType} from 'lexical';
 
 import {
   $applyNodeReplacement,
@@ -36,6 +36,7 @@ import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} f
 
 export type SerializedParagraphNode = Spread<
   {
+    textFormat: number;
     textStyle: string;
   },
   SerializedCommonBlockNode
@@ -43,12 +44,14 @@ export type SerializedParagraphNode = Spread<
 
 /** @noInheritDoc */
 export class ParagraphNode extends CommonBlockNode {
-  ['constructor']!: KlassConstructor<typeof ParagraphNode>;
+  declare ['constructor']: KlassConstructor<typeof ParagraphNode>;
   /** @internal */
+  __textFormat: number;
   __textStyle: string;
 
   constructor(key?: NodeKey) {
     super(key);
+    this.__textFormat = 0;
     this.__textStyle = '';
   }
 
@@ -56,6 +59,22 @@ export class ParagraphNode extends CommonBlockNode {
     return 'paragraph';
   }
 
+  getTextFormat(): number {
+    const self = this.getLatest();
+    return self.__textFormat;
+  }
+
+  setTextFormat(type: number): this {
+    const self = this.getWritable();
+    self.__textFormat = type;
+    return self;
+  }
+
+  hasTextFormat(type: TextFormatType): boolean {
+    const formatFlag = TEXT_TYPE_TO_FORMAT[type];
+    return (this.getTextFormat() & formatFlag) !== 0;
+  }
+
   getTextStyle(): string {
     const self = this.getLatest();
     return self.__textStyle;
@@ -73,6 +92,7 @@ export class ParagraphNode extends CommonBlockNode {
 
   afterCloneFrom(prevNode: this) {
     super.afterCloneFrom(prevNode);
+    this.__textFormat = prevNode.__textFormat;
     this.__textStyle = prevNode.__textStyle;
     copyCommonBlockProperties(prevNode, this);
   }
@@ -125,12 +145,14 @@ export class ParagraphNode extends CommonBlockNode {
   static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode {
     const node = $createParagraphNode();
     deserializeCommonBlockNode(serializedNode, node);
+    node.setTextFormat(serializedNode.textFormat);
     return node;
   }
 
   exportJSON(): SerializedParagraphNode {
     return {
       ...super.exportJSON(),
+      textFormat: this.getTextFormat(),
       textStyle: this.getTextStyle(),
       type: 'paragraph',
       version: 1,
@@ -144,6 +166,7 @@ export class ParagraphNode extends CommonBlockNode {
     restoreSelection: boolean,
   ): ParagraphNode {
     const newElement = $createParagraphNode();
+    newElement.setTextFormat(rangeSelection.format);
     newElement.setTextStyle(rangeSelection.style);
     const direction = this.getDirection();
     newElement.setDirection(direction);
diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
index 7f1b4f305..35cc073a0 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
@@ -284,7 +284,7 @@ export interface TextNode {
 /** @noInheritDoc */
 // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
 export class TextNode extends LexicalNode {
-  ['constructor']!: KlassConstructor<typeof TextNode>;
+  declare ['constructor']: KlassConstructor<typeof TextNode>;
   __text: string;
   /** @internal */
   __format: number;
@@ -620,6 +620,7 @@ export class TextNode extends LexicalNode {
   // HTML content and not have the ability to use CSS classes.
   exportDOM(editor: LexicalEditor): DOMExportOutput {
     let {element} = super.exportDOM(editor);
+    const originalElementName = (element?.nodeName || '').toLowerCase()
     invariant(
       element !== null && isHTMLElement(element),
       'Expected TextNode createDOM to always return a HTMLElement',
@@ -649,8 +650,8 @@ export class TextNode extends LexicalNode {
     // This is the only way to properly add support for most clients,
     // even if it's semantically incorrect to have to resort to using
     // <b>, <u>, 
, <i> elements.
-    if (this.hasFormat('bold')) {
-      element = wrapElementWith(element, 'b');
+    if (this.hasFormat('bold') && originalElementName !== 'strong') {
+      element = wrapElementWith(element, 'strong');
     }
     if (this.hasFormat('italic')) {
       element = wrapElementWith(element, 'em');
diff --git a/resources/js/wysiwyg/lexical/core/nodes/common.ts b/resources/js/wysiwyg/lexical/core/nodes/common.ts
index eac9c8295..f94fa1d52 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/common.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/common.ts
@@ -82,6 +82,38 @@ export function commonPropertiesDifferent(nodeA: CommonBlockInterface, nodeB: Co
         nodeA.__dir !== nodeB.__dir;
 }
 
+export function applyCommonPropertyChanges(prevNode: CommonBlockInterface, currentNode: CommonBlockInterface, element: HTMLElement): void {
+    if (prevNode.__id !== currentNode.__id) {
+        element.setAttribute('id', currentNode.__id);
+    }
+
+    if (prevNode.__alignment !== currentNode.__alignment) {
+        for (const alignment of validAlignments) {
+            element.classList.remove('align-' + alignment);
+        }
+
+        if (currentNode.__alignment) {
+            element.classList.add('align-' + currentNode.__alignment);
+        }
+    }
+
+    if (prevNode.__inset !== currentNode.__inset) {
+        if (currentNode.__inset) {
+            element.style.paddingLeft = `${currentNode.__inset}px`;
+        } else {
+            element.style.removeProperty('paddingLeft');
+        }
+    }
+
+    if (prevNode.__dir !== currentNode.__dir) {
+        if (currentNode.__dir) {
+            element.dir = currentNode.__dir;
+        } else {
+            element.removeAttribute('dir');
+        }
+    }
+}
+
 export function updateElementWithCommonBlockProps(element: HTMLElement, node: CommonBlockInterface): void {
     if (node.__id) {
         element.setAttribute('id', node.__id);
diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts
index 5018e10b4..de5e53bb8 100644
--- a/resources/js/wysiwyg/lexical/html/index.ts
+++ b/resources/js/wysiwyg/lexical/html/index.ts
@@ -85,7 +85,18 @@ export function $generateHtmlFromNodes(
     $appendNodesToHTML(editor, topLevelNode, container, selection);
   }
 
-  return container.innerHTML;
+  const nodeCode = [];
+  for (const node of container.childNodes) {
+    if ("outerHTML" in node) {
+      nodeCode.push(node.outerHTML)
+    } else {
+      const wrap = document.createElement('div');
+      wrap.appendChild(node.cloneNode(true));
+      nodeCode.push(wrap.innerHTML);
+    }
+  }
+
+  return nodeCode.join('\n');
 }
 
 function $appendNodesToHTML(
diff --git a/resources/js/wysiwyg/lexical/link/index.ts b/resources/js/wysiwyg/lexical/link/index.ts
index 884fe9153..336bb1546 100644
--- a/resources/js/wysiwyg/lexical/link/index.ts
+++ b/resources/js/wysiwyg/lexical/link/index.ts
@@ -48,14 +48,6 @@ export type SerializedLinkNode = Spread<
 
 type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
 
-const SUPPORTED_URL_PROTOCOLS = new Set([
-  'http:',
-  'https:',
-  'mailto:',
-  'sms:',
-  'tel:',
-]);
-
 /** @noInheritDoc */
 export class LinkNode extends ElementNode {
   /** @internal */
@@ -90,7 +82,7 @@ export class LinkNode extends ElementNode {
 
   createDOM(config: EditorConfig): LinkHTMLElementType {
     const element = document.createElement('a');
-    element.href = this.sanitizeUrl(this.__url);
+    element.href = this.__url;
     if (this.__target !== null) {
       element.target = this.__target;
     }
@@ -166,19 +158,6 @@ export class LinkNode extends ElementNode {
     return node;
   }
 
-  sanitizeUrl(url: string): string {
-    try {
-      const parsedUrl = new URL(url);
-      // eslint-disable-next-line no-script-url
-      if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
-        return 'about:blank';
-      }
-    } catch {
-      return url;
-    }
-    return url;
-  }
-
   exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
     return {
       ...super.exportJSON(),
diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts
index 3c845359a..cdf32fdcb 100644
--- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts
+++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts
@@ -75,6 +75,9 @@ export class DetailsNode extends ElementNode {
 
         if (this.__open) {
             el.setAttribute('open', 'true');
+            el.removeAttribute('contenteditable');
+        } else {
+            el.setAttribute('contenteditable', 'false');
         }
 
         const summary = document.createElement('summary');
@@ -84,7 +87,7 @@ export class DetailsNode extends ElementNode {
             event.preventDefault();
             _editor.update(() => {
                 this.select();
-            })
+            });
         });
 
         el.append(summary);
@@ -96,6 +99,11 @@ export class DetailsNode extends ElementNode {
 
         if (prevNode.__open !== this.__open) {
             dom.toggleAttribute('open', this.__open);
+            if (this.__open) {
+                dom.removeAttribute('contenteditable');
+            } else {
+                dom.setAttribute('contenteditable', 'false');
+            }
         }
 
         return prevNode.__id !== this.__id
@@ -144,6 +152,7 @@ export class DetailsNode extends ElementNode {
         }
 
         element.removeAttribute('open');
+        element.removeAttribute('contenteditable');
 
         return {element};
     }
@@ -165,6 +174,14 @@ export class DetailsNode extends ElementNode {
         return node;
     }
 
+    shouldSelectDirectly(): boolean {
+        return true;
+    }
+
+    canBeEmpty(): boolean {
+        return false;
+    }
+
 }
 
 export function $createDetailsNode() {
diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts
index 81fb96a93..a7acc4ad3 100644
--- a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts
+++ b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts
@@ -8,15 +8,13 @@ import {
 } from 'lexical';
 import type {EditorConfig} from "lexical/LexicalEditor";
 
-import {el, setOrRemoveAttribute, sizeToPixels} from "../../utils/dom";
+import {el, setOrRemoveAttribute, sizeToPixels, styleMapToStyleString, styleStringToStyleMap} from "../../utils/dom";
 import {
     CommonBlockAlignment, deserializeCommonBlockNode,
     setCommonBlockPropsFromElement,
     updateElementWithCommonBlockProps
 } from "lexical/nodes/common";
-import {$selectSingleNode} from "../../utils/selection";
 import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
-import * as url from "node:url";
 
 export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
 export type MediaNodeSource = {
@@ -47,6 +45,19 @@ function filterAttributes(attributes: Record<string, string>): Record<string, st
     return filtered;
 }
 
+function removeStyleFromAttributes(attributes: Record<string, string>, styleName: string): Record<string, string> {
+    const attrCopy = Object.assign({}, attributes);
+    if (!attributes.style) {
+        return attrCopy;
+    }
+
+    const map = styleStringToStyleMap(attributes.style);
+    map.delete(styleName);
+
+    attrCopy.style = styleMapToStyleString(map);
+    return attrCopy;
+}
+
 function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode {
     const node = $createMediaNode(tag);
 
@@ -119,7 +130,7 @@ export class MediaNode extends ElementNode {
 
     getAttributes(): Record<string, string> {
         const self = this.getLatest();
-        return self.__attributes;
+        return Object.assign({}, self.__attributes);
     }
 
     setSources(sources: MediaNodeSource[]) {
@@ -129,25 +140,37 @@ export class MediaNode extends ElementNode {
 
     getSources(): MediaNodeSource[] {
         const self = this.getLatest();
-        return self.__sources;
+        return self.__sources.map(s => Object.assign({}, s))
     }
 
     setSrc(src: string): void {
-        const attrs = Object.assign({}, this.getAttributes());
+        const attrs = this.getAttributes();
+        const sources = this.getSources();
+
         if (this.__tag ==='object') {
             attrs.data = src;
+        } if (this.__tag === 'video' && sources.length > 0) {
+            sources[0].src = src;
+            delete attrs.src;
+            if (sources.length > 1) {
+                sources.splice(1, sources.length - 1);
+            }
+            this.setSources(sources);
         } else {
             attrs.src = src;
         }
+
         this.setAttributes(attrs);
     }
 
     setWidthAndHeight(width: string, height: string): void {
-        const attrs = Object.assign(
-            {},
+        let attrs: Record<string, string> = Object.assign(
             this.getAttributes(),
             {width, height},
         );
+
+        attrs = removeStyleFromAttributes(attrs, 'width');
+        attrs = removeStyleFromAttributes(attrs, 'height');
         this.setAttributes(attrs);
     }
 
@@ -186,8 +209,8 @@ export class MediaNode extends ElementNode {
             return;
         }
 
-        const attrs = Object.assign({}, this.getAttributes(), {height});
-        this.setAttributes(attrs);
+        const attrs = Object.assign(this.getAttributes(), {height});
+        this.setAttributes(removeStyleFromAttributes(attrs, 'height'));
     }
 
     getHeight(): number {
@@ -196,8 +219,9 @@ export class MediaNode extends ElementNode {
     }
 
     setWidth(width: number): void {
-        const attrs = Object.assign({}, this.getAttributes(), {width});
-        this.setAttributes(attrs);
+        const existingAttrs = this.getAttributes();
+        const attrs: Record<string, string> = Object.assign(existingAttrs, {width});
+        this.setAttributes(removeStyleFromAttributes(attrs, 'width'));
     }
 
     getWidth(): number {
@@ -223,15 +247,9 @@ export class MediaNode extends ElementNode {
 
     createDOM(_config: EditorConfig, _editor: LexicalEditor) {
         const media = this.createInnerDOM();
-        const wrap = el('span', {
+        return el('span', {
             class: media.className + ' editor-media-wrap',
         }, [media]);
-
-        wrap.addEventListener('click', e => {
-            _editor.update(() => $selectSingleNode(this));
-        });
-
-        return wrap;
     }
 
     updateDOM(prevNode: MediaNode, dom: HTMLElement): boolean {
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts
index 1fc6b42bb..1c9d7ecf6 100644
--- a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts
+++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts
@@ -353,10 +353,17 @@ export function $convertTableCellNodeElement(
   const hasUnderlineTextDecoration = textDecoration.includes('underline');
 
   if (domNode instanceof HTMLElement) {
-    tableCellNode.setStyles(extractStyleMapFromElement(domNode));
+    const styleMap = extractStyleMapFromElement(domNode);
+    styleMap.delete('background-color');
+    tableCellNode.setStyles(styleMap);
     tableCellNode.setAlignment(extractAlignmentFromElement(domNode));
   }
 
+  const background = style.backgroundColor || null;
+  if (background) {
+    tableCellNode.setBackgroundColor(background);
+  }
+
   return {
     after: (childLexicalNodes) => {
       if (childLexicalNodes.length === 0) {
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts
index a10361475..460223bc9 100644
--- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts
+++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts
@@ -30,12 +30,13 @@ import {TableDOMCell, TableDOMTable} from './LexicalTableObserver';
 import {getTable} from './LexicalTableSelectionHelpers';
 import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
 import {
+  applyCommonPropertyChanges,
   commonPropertiesDifferent, deserializeCommonBlockNode,
   setCommonBlockPropsFromElement,
   updateElementWithCommonBlockProps
 } from "lexical/nodes/common";
 import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom";
-import {getTableColumnWidths} from "../../utils/tables";
+import {buildColgroupFromTableWidths, getTableColumnWidths} from "../../utils/tables";
 
 export type SerializedTableNode = Spread<{
   colWidths: string[];
@@ -54,7 +55,7 @@ export class TableNode extends CommonBlockNode {
   static clone(node: TableNode): TableNode {
     const newNode = new TableNode(node.__key);
     copyCommonBlockProperties(node, newNode);
-    newNode.__colWidths = node.__colWidths;
+    newNode.__colWidths = [...node.__colWidths];
     newNode.__styles = new Map(node.__styles);
     return newNode;
   }
@@ -98,15 +99,8 @@ export class TableNode extends CommonBlockNode {
     updateElementWithCommonBlockProps(tableElement, this);
 
     const colWidths = this.getColWidths();
-    if (colWidths.length > 0) {
-      const colgroup = el('colgroup');
-      for (const width of colWidths) {
-        const col = el('col');
-        if (width) {
-          col.style.width = width;
-        }
-        colgroup.append(col);
-      }
+    const colgroup = buildColgroupFromTableWidths(colWidths);
+    if (colgroup) {
       tableElement.append(colgroup);
     }
 
@@ -117,11 +111,29 @@ export class TableNode extends CommonBlockNode {
     return tableElement;
   }
 
-  updateDOM(_prevNode: TableNode): boolean {
-    return commonPropertiesDifferent(_prevNode, this)
-      || this.__colWidths.join(':') !== _prevNode.__colWidths.join(':')
-      || this.__styles.size !== _prevNode.__styles.size
-      || (Array.from(this.__styles.values()).join(':') !== (Array.from(_prevNode.__styles.values()).join(':')));
+  updateDOM(_prevNode: TableNode, dom: HTMLElement): boolean {
+    applyCommonPropertyChanges(_prevNode, this, dom);
+
+    if (this.__colWidths.join(':') !== _prevNode.__colWidths.join(':')) {
+      const existingColGroup = Array.from(dom.children).find(child => child.nodeName === 'COLGROUP');
+      const newColGroup = buildColgroupFromTableWidths(this.__colWidths);
+      if (existingColGroup) {
+        existingColGroup.remove();
+      }
+
+      if (newColGroup) {
+        dom.prepend(newColGroup);
+      }
+    }
+
+    if (Array.from(this.__styles.values()).join(':') !== Array.from(_prevNode.__styles.values()).join(':')) {
+      dom.style.cssText = '';
+      for (const [name, value] of this.__styles.entries()) {
+        dom.style.setProperty(name, value);
+      }
+    }
+
+    return false;
   }
 
   exportDOM(editor: LexicalEditor): DOMExportOutput {
@@ -169,7 +181,7 @@ export class TableNode extends CommonBlockNode {
 
   getColWidths(): string[] {
     const self = this.getLatest();
-    return self.__colWidths;
+    return [...self.__colWidths];
   }
 
   getStyles(): StyleMap {
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts
index e098a21e4..6e5e5416f 100644
--- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts
+++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts
@@ -71,6 +71,7 @@ import {TableDOMTable, TableObserver} from './LexicalTableObserver';
 import {$isTableRowNode} from './LexicalTableRowNode';
 import {$isTableSelection} from './LexicalTableSelection';
 import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
+import {$selectOrCreateAdjacent} from "../../utils/nodes";
 
 const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
 
@@ -915,9 +916,14 @@ export function getTable(tableElement: HTMLElement): TableDOMTable {
   domRows.length = 0;
 
   while (currentNode != null) {
-    const nodeMame = currentNode.nodeName;
+    const nodeName = currentNode.nodeName;
 
-    if (nodeMame === 'TD' || nodeMame === 'TH') {
+    if (nodeName === 'COLGROUP' || nodeName === 'CAPTION') {
+      currentNode = currentNode.nextSibling;
+      continue;
+    }
+
+    if (nodeName === 'TD' || nodeName === 'TH') {
       const elem = currentNode as HTMLElement;
       const cell = {
         elem,
@@ -1108,7 +1114,7 @@ const selectTableNodeInDirection = (
           false,
         );
       } else {
-        tableNode.selectPrevious();
+        $selectOrCreateAdjacent(tableNode, false);
       }
 
       return true;
@@ -1120,7 +1126,7 @@ const selectTableNodeInDirection = (
           true,
         );
       } else {
-        tableNode.selectNext();
+        $selectOrCreateAdjacent(tableNode, true);
       }
 
       return true;
diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts b/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts
index cdbc84658..bd807d7f9 100644
--- a/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts
+++ b/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts
@@ -35,6 +35,7 @@ import {
   TableRowNode,
 } from './LexicalTableRowNode';
 import {$isTableSelection} from './LexicalTableSelection';
+import {$isCaptionNode} from "@lexical/table/LexicalCaptionNode";
 
 export function $createTableNodeWithDimensions(
   rowCount: number,
@@ -779,7 +780,7 @@ export function $computeTableMapSkipCellCheck(
     return tableMap[row] === undefined || tableMap[row][column] === undefined;
   }
 
-  const gridChildren = grid.getChildren();
+  const gridChildren = grid.getChildren().filter(node => !$isCaptionNode(node));
   for (let i = 0; i < gridChildren.length; i++) {
     const row = gridChildren[i];
     invariant(
diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts
index c1db0f086..413e2c4cd 100644
--- a/resources/js/wysiwyg/nodes.ts
+++ b/resources/js/wysiwyg/nodes.ts
@@ -20,9 +20,6 @@ import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
 import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
 import {CaptionNode} from "@lexical/table/LexicalCaptionNode";
 
-/**
- * Load the nodes for lexical.
- */
 export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
     return [
         CalloutNode,
@@ -45,6 +42,15 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
     ];
 }
 
+export function getNodesForBasicEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
+    return [
+        ListNode,
+        ListItemNode,
+        ParagraphNode,
+        LinkNode,
+    ];
+}
+
 export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
     const decorated = [ImageNode, CodeBlockNode, DiagramNode];
 
@@ -53,7 +59,7 @@ export function registerCommonNodeMutationListeners(context: EditorUiContext): v
             if (mutation === "destroyed") {
                 const decorator = context.manager.getDecoratorByNodeKey(nodeKey);
                 if (decorator) {
-                    decorator.destroy(context);
+                    decorator.teardown();
                 }
             }
         }
diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts
index 2ffa722e4..f7fc81cb3 100644
--- a/resources/js/wysiwyg/services/common-events.ts
+++ b/resources/js/wysiwyg/services/common-events.ts
@@ -1,4 +1,4 @@
-import {$getSelection, LexicalEditor} from "lexical";
+import {LexicalEditor} from "lexical";
 import {
     appendHtmlToEditor,
     focusEditor,
diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts
index 2ee831d74..57f9a80ae 100644
--- a/resources/js/wysiwyg/services/drop-paste-handling.ts
+++ b/resources/js/wysiwyg/services/drop-paste-handling.ts
@@ -95,6 +95,21 @@ function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolea
     return handled;
 }
 
+function handleImageLinkInsert(data: DataTransfer, context: EditorUiContext): boolean {
+    const regex = /https?:\/\/([^?#]*?)\.(png|jpeg|jpg|gif|webp|bmp|avif)/i
+    const text = data.getData('text/plain');
+    if (text && regex.test(text)) {
+        context.editor.update(() => {
+            const image = $createImageNode(text);
+            $insertNodes([image]);
+            image.select();
+        });
+        return true;
+    }
+
+    return false;
+}
+
 function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean {
     const editor = context.editor;
     return (event: DragEvent): boolean => {
@@ -138,7 +153,10 @@ function createPasteListener(context: EditorUiContext): (event: ClipboardEvent)
             return false;
         }
 
-        const handled = handleMediaInsert(event.clipboardData, context);
+        const handled =
+            handleImageLinkInsert(event.clipboardData, context) ||
+            handleMediaInsert(event.clipboardData, context);
+
         if (handled) {
             event.preventDefault();
         }
diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts
index a7f1ec7f0..aba0b1aa8 100644
--- a/resources/js/wysiwyg/services/keyboard-handling.ts
+++ b/resources/js/wysiwyg/services/keyboard-handling.ts
@@ -13,15 +13,17 @@ import {
 import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
 import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
 import {getLastSelection} from "../utils/selection";
-import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes";
+import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes";
 import {$setInsetForSelection} from "../utils/lists";
 import {$isListItemNode} from "@lexical/list";
 import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+import {$isDiagramNode} from "../utils/diagrams";
+import {$unwrapDetailsNode} from "../utils/details";
 
 function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
     if (nodes.length === 1) {
         const node = nodes[0];
-        if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) {
+        if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node)) {
             return true;
         }
     }
@@ -46,16 +48,21 @@ function deleteSingleSelectedNode(editor: LexicalEditor) {
  * Insert a new empty node before/after the selection if the selection contains a single
  * selected node (like image, media etc...).
  */
-function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
+function insertAdjacentToSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
     const selectionNodes = getLastSelection(editor)?.getNodes() || [];
     if (isSingleSelectedNode(selectionNodes)) {
         const node = selectionNodes[0];
         const nearestBlock = $getNearestNodeBlockParent(node) || node;
+        const insertBefore = event?.shiftKey === true;
         if (nearestBlock) {
             requestAnimationFrame(() => {
                 editor.update(() => {
                     const newParagraph = $createParagraphNode();
-                    nearestBlock.insertAfter(newParagraph);
+                    if (insertBefore) {
+                        nearestBlock.insertBefore(newParagraph);
+                    } else {
+                        nearestBlock.insertAfter(newParagraph);
+                    }
                     newParagraph.select();
                 });
             });
@@ -74,22 +81,9 @@ function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event:
     }
 
     event?.preventDefault();
-
     const node = selectionNodes[0];
-    const nearestBlock = $getNearestNodeBlockParent(node) || node;
-    let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling();
-
     editor.update(() => {
-        if (!target) {
-            target = $createParagraphNode();
-            if (after) {
-                nearestBlock.insertAfter(target)
-            } else {
-                nearestBlock.insertBefore(target);
-            }
-        }
-
-        target.selectStart();
+        $selectOrCreateAdjacent(node, after);
     });
 
     return true;
@@ -179,6 +173,35 @@ function getDetailsScenario(editor: LexicalEditor): {
     }
 }
 
+function unwrapDetailsNode(context: EditorUiContext, event: KeyboardEvent): boolean {
+    const selection = $getSelection();
+    const nodes = selection?.getNodes() || [];
+
+    if (nodes.length !== 1) {
+        return false;
+    }
+
+    const selectedNearestBlock = $getNearestNodeBlockParent(nodes[0]);
+    if (!selectedNearestBlock) {
+        return false;
+    }
+
+    const selectedParentBlock = selectedNearestBlock.getParent();
+    const selectRange = selection?.getStartEndPoints();
+
+    if (selectRange && $isDetailsNode(selectedParentBlock) && selectRange[0].offset === 0 && selectedNearestBlock.getIndexWithinParent() === 0) {
+        event.preventDefault();
+        context.editor.update(() => {
+            $unwrapDetailsNode(selectedParentBlock);
+            selectedNearestBlock.selectStart();
+            context.manager.triggerLayoutUpdate();
+        });
+        return true;
+    }
+
+    return false;
+}
+
 function $isSingleListItem(nodes: LexicalNode[]): boolean {
     if (nodes.length !== 1) {
         return false;
@@ -208,9 +231,9 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo
 }
 
 export function registerKeyboardHandling(context: EditorUiContext): () => void {
-    const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
+    const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event): boolean => {
         deleteSingleSelectedNode(context.editor);
-        return false;
+        return unwrapDetailsNode(context, event);
     }, COMMAND_PRIORITY_LOW);
 
     const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => {
@@ -219,7 +242,7 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
     }, COMMAND_PRIORITY_LOW);
 
     const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
-        return insertAfterSingleSelectedNode(context.editor, event)
+        return insertAdjacentToSingleSelectedNode(context.editor, event)
             || moveAfterDetailsOnEmptyLine(context.editor, event);
     }, COMMAND_PRIORITY_LOW);
 
diff --git a/resources/js/wysiwyg/services/mouse-handling.ts b/resources/js/wysiwyg/services/mouse-handling.ts
new file mode 100644
index 000000000..100dd44ff
--- /dev/null
+++ b/resources/js/wysiwyg/services/mouse-handling.ts
@@ -0,0 +1,72 @@
+import {EditorUiContext} from "../ui/framework/core";
+import {
+    $createParagraphNode, $getNearestNodeFromDOMNode, $getRoot,
+    $isDecoratorNode, CLICK_COMMAND,
+    COMMAND_PRIORITY_LOW, ElementNode,
+    LexicalNode
+} from "lexical";
+import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
+import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
+import {$isDiagramNode} from "../utils/diagrams";
+import {$isTableNode} from "@lexical/table";
+import {$isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+
+function isHardToEscapeNode(node: LexicalNode): boolean {
+    return $isDecoratorNode(node)
+        || $isImageNode(node)
+        || $isMediaNode(node)
+        || $isDiagramNode(node)
+        || $isTableNode(node)
+        || $isDetailsNode(node);
+}
+
+function $getContextNode(event: MouseEvent): ElementNode {
+    if (event.target instanceof HTMLElement) {
+        const nearestDetails = event.target.closest('details');
+        if (nearestDetails) {
+            const detailsNode = $getNearestNodeFromDOMNode(nearestDetails);
+            if ($isDetailsNode(detailsNode)) {
+                return detailsNode;
+            }
+        }
+    }
+    return $getRoot();
+}
+
+function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boolean {
+    const contextNode = $getContextNode(event);
+    const lastNode = contextNode.getLastChild();
+    if (!lastNode || !isHardToEscapeNode(lastNode)) {
+        return false;
+    }
+
+    const lastNodeDom = context.editor.getElementByKey(lastNode.getKey());
+    if (!lastNodeDom) {
+        return false;
+    }
+
+    const nodeBounds = lastNodeDom.getBoundingClientRect();
+    const isClickBelow = event.clientY > nodeBounds.bottom;
+    if (isClickBelow) {
+        context.editor.update(() => {
+            const newNode = $createParagraphNode();
+            contextNode.append(newNode);
+            newNode.select();
+        });
+        return true;
+    }
+
+    return false;
+}
+
+export function registerMouseHandling(context: EditorUiContext): () => void {
+    const unregisterClick = context.editor.registerCommand(CLICK_COMMAND, (event): boolean => {
+        insertBelowLastNode(context, event);
+        return false;
+    }, COMMAND_PRIORITY_LOW);
+
+
+    return () => {
+        unregisterClick();
+    };
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/services/selection-handling.ts b/resources/js/wysiwyg/services/selection-handling.ts
new file mode 100644
index 000000000..e3ea965f2
--- /dev/null
+++ b/resources/js/wysiwyg/services/selection-handling.ts
@@ -0,0 +1,49 @@
+import {EditorUiContext} from "../ui/framework/core";
+import {
+    $getSelection,
+    COMMAND_PRIORITY_LOW,
+    SELECTION_CHANGE_COMMAND
+} from "lexical";
+import {$isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+
+
+const trackedDomNodes = new Set<HTMLElement>();
+
+/**
+ * Set a selection indicator on nodes which require it.
+ * @param context
+ */
+function setSelectionIndicator(context: EditorUiContext): boolean {
+
+    for (const domNode of trackedDomNodes) {
+        domNode.classList.remove('selected');
+        trackedDomNodes.delete(domNode);
+    }
+
+    const selection = $getSelection();
+    const nodes = selection?.getNodes() || [];
+
+    if (nodes.length === 1) {
+        if ($isDetailsNode(nodes[0])) {
+            const domEl = context.editor.getElementByKey(nodes[0].getKey());
+            if (domEl) {
+                domEl.classList.add('selected');
+                trackedDomNodes.add(domEl);
+            }
+        }
+    }
+
+    return false;
+}
+
+export function registerSelectionHandling(context: EditorUiContext): () => void {
+    const unregisterSelectionChange = context.editor.registerCommand(SELECTION_CHANGE_COMMAND, (): boolean => {
+        setSelectionIndicator(context);
+        return false;
+    }, COMMAND_PRIORITY_LOW);
+
+
+    return () => {
+        unregisterSelectionChange();
+    };
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts
index 0384a3bf1..ead4c38d4 100644
--- a/resources/js/wysiwyg/services/shortcuts.ts
+++ b/resources/js/wysiwyg/services/shortcuts.ts
@@ -13,14 +13,16 @@ import {$showLinkForm} from "../ui/defaults/forms/objects";
 import {showLinkSelector} from "../utils/links";
 import {HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode";
 
-function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean {
-    toggleSelectionAsHeading(editor, tag);
+function headerHandler(context: EditorUiContext, tag: HeadingTagType): boolean {
+    toggleSelectionAsHeading(context.editor, tag);
+    context.manager.triggerFutureStateRefresh();
     return true;
 }
 
 function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction {
-    return (editor: LexicalEditor) => {
+    return (editor: LexicalEditor, context: EditorUiContext) => {
         formatAction(editor);
+        context.manager.triggerFutureStateRefresh();
         return true;
     };
 }
@@ -45,10 +47,10 @@ const actionsByKeys: Record<string, ShortcutAction> = {
         window.$events.emit('editor-save-page');
         return true;
     },
-    'meta+1': (editor) => headerHandler(editor, 'h1'),
-    'meta+2': (editor) => headerHandler(editor, 'h2'),
-    'meta+3': (editor) => headerHandler(editor, 'h3'),
-    'meta+4': (editor) => headerHandler(editor, 'h4'),
+    'meta+1': (editor, context) => headerHandler(context, 'h2'),
+    'meta+2': (editor, context) => headerHandler(context, 'h3'),
+    'meta+3': (editor, context) => headerHandler(context, 'h4'),
+    'meta+4': (editor, context) => headerHandler(context, 'h5'),
     'meta+5': wrapFormatAction(toggleSelectionAsParagraph),
     'meta+d': wrapFormatAction(toggleSelectionAsParagraph),
     'meta+6': wrapFormatAction(toggleSelectionAsBlockquote),
diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts
index d53bcb482..52a73ad72 100644
--- a/resources/js/wysiwyg/ui/decorators/diagram.ts
+++ b/resources/js/wysiwyg/ui/decorators/diagram.ts
@@ -1,6 +1,6 @@
 import {EditorDecorator} from "../framework/decorator";
 import {EditorUiContext} from "../framework/core";
-import {BaseSelection} from "lexical";
+import {BaseSelection, CLICK_COMMAND, COMMAND_PRIORITY_NORMAL} from "lexical";
 import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
 import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection";
 import {$openDrawingEditorForNode} from "../../utils/diagrams";
@@ -12,11 +12,17 @@ export class DiagramDecorator extends EditorDecorator {
     setup(context: EditorUiContext, element: HTMLElement) {
         const diagramNode = this.getNode();
         element.classList.add('editor-diagram');
-        element.addEventListener('click', event => {
+
+        context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => {
+            if (!element.contains(event.target as HTMLElement)) {
+                return false;
+            }
+
             context.editor.update(() => {
                 $selectSingleNode(this.getNode());
-            })
-        });
+            });
+            return true;
+        }, COMMAND_PRIORITY_NORMAL);
 
         element.addEventListener('dblclick', event => {
             context.editor.getEditorState().read(() => {
diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts
index c5b7ad29a..dea78d24f 100644
--- a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts
+++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts
@@ -13,7 +13,6 @@ import codeIcon from "@icons/editor/code.svg";
 import formatClearIcon from "@icons/editor/format-clear.svg";
 import {$selectionContainsTextFormat} from "../../../utils/selection";
 import {$patchStyleText} from "@lexical/selection";
-import {context} from "esbuild";
 
 function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition {
     return {
@@ -32,7 +31,7 @@ export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', bo
 export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon);
 export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon);
 export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon};
-export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon};
+export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color', icon: highlightIcon};
 
 function colorAction(context: EditorUiContext, property: string, color: string): void {
     context.editor.update(() => {
@@ -44,7 +43,7 @@ function colorAction(context: EditorUiContext, property: string, color: string):
 }
 
 export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color);
-export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color);
+export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'background-color', color);
 
 export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon);
 export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon);
diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts
index 4eb4c5a4e..00dc9500e 100644
--- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts
+++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts
@@ -34,6 +34,7 @@ import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert}
 import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
 import {$showDetailsForm, $showImageForm, $showLinkForm, $showMediaForm} from "../forms/objects";
 import {formatCodeBlock} from "../../../utils/formats";
+import {$unwrapDetailsNode} from "../../../utils/details";
 
 export const link: EditorButtonDefinition = {
     label: 'Insert/edit link',
@@ -92,6 +93,7 @@ export const image: EditorButtonDefinition = {
                 context.editor.update(() => {
                     const link = $createLinkedImageNodeFromImageData(image);
                     $insertNodes([link]);
+                    link.select();
                 });
             })
         });
@@ -192,6 +194,8 @@ export const details: EditorButtonDefinition = {
                 .filter(n => n !== null) as ElementNode[];
             const uniqueTopLevels = [...new Set(topLevels)];
 
+            detailsNode.setOpen(true);
+
             if (uniqueTopLevels.length > 0) {
                 uniqueTopLevels[0].insertAfter(detailsNode);
             } else {
@@ -248,11 +252,7 @@ export const detailsUnwrap: EditorButtonDefinition = {
         context.editor.update(() => {
             const details = $getNodeFromSelection($getSelection(), $isDetailsNode);
             if ($isDetailsNode(details)) {
-                const children = details.getChildren();
-                for (const child of children) {
-                    details.insertBefore(child);
-                }
-                details.remove();
+                $unwrapDetailsNode(details);
                 context.manager.triggerLayoutUpdate();
             }
         })
diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts
index 0effdc171..cdf464cb4 100644
--- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts
+++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts
@@ -192,11 +192,17 @@ export function $showMediaForm(media: MediaNode|null, context: EditorUiContext):
     let formDefaults = {};
     if (media) {
         const nodeAttrs = media.getAttributes();
+        const nodeDOM = media.exportDOM(context.editor).element;
+        const nodeHtml = (nodeDOM instanceof HTMLElement) ? nodeDOM.outerHTML : '';
+
         formDefaults = {
-            src: nodeAttrs.src || nodeAttrs.data || '',
+            src: nodeAttrs.src || nodeAttrs.data || media.getSources()[0]?.src || '',
             width: nodeAttrs.width,
             height: nodeAttrs.height,
-            embed: '',
+            embed: nodeHtml,
+
+            // This is used so we can check for edits against the embed field on submit
+            embed_check: nodeHtml,
         }
     }
 
@@ -214,7 +220,8 @@ export const media: EditorFormDefinition = {
         }));
 
         const embedCode = (formData.get('embed') || '').toString().trim();
-        if (embedCode) {
+        const embedCheck = (formData.get('embed_check') || '').toString().trim();
+        if (embedCode && embedCode !== embedCheck) {
             context.editor.update(() => {
                 const node = $createMediaNodeFromHtml(embedCode);
                 if (selectedNode && node) {
@@ -236,6 +243,7 @@ export const media: EditorFormDefinition = {
             if (selectedNode) {
                 selectedNode.setSrc(src);
                 selectedNode.setWidthAndHeight(width, height);
+                context.manager.triggerFutureStateRefresh();
                 return;
             }
 
@@ -281,6 +289,11 @@ export const media: EditorFormDefinition = {
                                 name: 'embed',
                                 type: 'textarea',
                             },
+                            {
+                                label: '',
+                                name: 'embed_check',
+                                type: 'hidden',
+                            },
                         ],
                     }
                 ])
diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts
index 5b484310d..031e00983 100644
--- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts
+++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts
@@ -75,7 +75,7 @@ export function $showCellPropertiesForm(cell: TableCellNode, context: EditorUiCo
         border_width: styles.get('border-width') || '',
         border_style: styles.get('border-style') || '',
         border_color: styles.get('border-color') || '',
-        background_color: styles.get('background-color') || '',
+        background_color: cell.getBackgroundColor() || styles.get('background-color') || '',
     });
     return modalForm;
 }
@@ -91,6 +91,7 @@ export const cellProperties: EditorFormDefinition = {
                 $setTableCellColumnWidth(cell, width);
                 cell.updateTag(formData.get('type')?.toString() || '');
                 cell.setAlignment((formData.get('h_align')?.toString() || '') as CommonBlockAlignment);
+                cell.setBackgroundColor(formData.get('background_color')?.toString() || '');
 
                 const styles = cell.getStyles();
                 styles.set('height', formatSizeValue(formData.get('height')?.toString() || ''));
@@ -98,7 +99,6 @@ export const cellProperties: EditorFormDefinition = {
                 styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || ''));
                 styles.set('border-style', formData.get('border_style')?.toString() || '');
                 styles.set('border-color', formData.get('border_color')?.toString() || '');
-                styles.set('background-color', formData.get('background_color')?.toString() || '');
 
                 cell.setStyles(styles);
             }
diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts
index b09a7530f..9302e7bed 100644
--- a/resources/js/wysiwyg/ui/defaults/toolbars.ts
+++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts
@@ -79,6 +79,7 @@ import {
 import {el} from "../../utils/dom";
 import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu";
 import {EditorSeparator} from "../framework/blocks/separator";
+import {EditorContextToolbarDefinition} from "../framework/toolbars";
 
 export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement {
 
@@ -87,7 +88,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
     return new EditorSimpleClassContainer('editor-toolbar-main', [
 
         // History state
-        new EditorOverflowContainer(2, [
+        new EditorOverflowContainer('history', 2, [
             new EditorButton(undo),
             new EditorButton(redo),
         ]),
@@ -109,7 +110,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
         ]),
 
         // Inline formats
-        new EditorOverflowContainer(6, [
+        new EditorOverflowContainer('inline_formats', 6, [
             new EditorButton(bold),
             new EditorButton(italic),
             new EditorButton(underline),
@@ -127,7 +128,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
         ]),
 
         // Alignment
-        new EditorOverflowContainer(6, [
+        new EditorOverflowContainer('alignment', 6, [
             new EditorButton(alignLeft),
             new EditorButton(alignCenter),
             new EditorButton(alignRight),
@@ -137,7 +138,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
         ].filter(x => x !== null)),
 
         // Lists
-        new EditorOverflowContainer(3, [
+        new EditorOverflowContainer('lists', 3, [
             new EditorButton(bulletList),
             new EditorButton(numberList),
             new EditorButton(taskList),
@@ -146,7 +147,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
         ]),
 
         // Insert types
-        new EditorOverflowContainer(4, [
+        new EditorOverflowContainer('inserts', 4, [
             new EditorButton(link),
 
             new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [
@@ -199,7 +200,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
         ]),
 
         // Meta elements
-        new EditorOverflowContainer(3, [
+        new EditorOverflowContainer('meta', 3, [
             new EditorButton(source),
             new EditorButton(about),
             new EditorButton(fullscreen),
@@ -220,46 +221,76 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
     ]);
 }
 
-export function getImageToolbarContent(): EditorUiElement[] {
-    return [new EditorButton(image)];
-}
-
-export function getLinkToolbarContent(): EditorUiElement[] {
-    return [
-        new EditorButton(link),
-        new EditorButton(unlink),
-    ];
-}
-
-export function getCodeToolbarContent(): EditorUiElement[] {
-    return [
-        new EditorButton(editCodeBlock),
-    ];
-}
-
-export function getTableToolbarContent(): EditorUiElement[] {
-    return [
-        new EditorOverflowContainer(2, [
-            new EditorButton(tableProperties),
-            new EditorButton(deleteTable),
-        ]),
-        new EditorOverflowContainer(3, [
-            new EditorButton(insertRowAbove),
-            new EditorButton(insertRowBelow),
-            new EditorButton(deleteRow),
-        ]),
-        new EditorOverflowContainer(3, [
-            new EditorButton(insertColumnBefore),
-            new EditorButton(insertColumnAfter),
-            new EditorButton(deleteColumn),
-        ]),
-    ];
+export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement {
+    return new EditorSimpleClassContainer('editor-toolbar-main', [
+        new EditorOverflowContainer('formats', 7, [
+            new EditorButton(bold),
+            new EditorButton(italic),
+            new EditorButton(link),
+            new EditorButton(bulletList),
+            new EditorButton(numberList),
+        ])
+    ]);
 }
 
-export function getDetailsToolbarContent(): EditorUiElement[] {
-    return [
-        new EditorButton(detailsEditLabel),
-        new EditorButton(detailsToggle),
-        new EditorButton(detailsUnwrap),
-    ];
-}
\ No newline at end of file
+export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
+    image: {
+        selector: 'img:not([drawio-diagram] img)',
+        content: () => [new EditorButton(image)],
+    },
+    media: {
+        selector: '.editor-media-wrap',
+        content: () => [new EditorButton(media)],
+    },
+    link: {
+        selector: 'a',
+        content() {
+            return [
+                new EditorButton(link),
+                new EditorButton(unlink),
+            ]
+        },
+        displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
+            const image = originalTarget.querySelector('img');
+            return image || originalTarget;
+        }
+    },
+    code: {
+        selector: '.editor-code-block-wrap',
+        content: () => [new EditorButton(editCodeBlock)],
+    },
+    table: {
+        selector: 'td,th',
+        content() {
+            return [
+                new EditorOverflowContainer('table', 2, [
+                    new EditorButton(tableProperties),
+                    new EditorButton(deleteTable),
+                ]),
+                new EditorOverflowContainer('table_row',3, [
+                    new EditorButton(insertRowAbove),
+                    new EditorButton(insertRowBelow),
+                    new EditorButton(deleteRow),
+                ]),
+                new EditorOverflowContainer('table_column', 3, [
+                    new EditorButton(insertColumnBefore),
+                    new EditorButton(insertColumnAfter),
+                    new EditorButton(deleteColumn),
+                ]),
+            ];
+        },
+        displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
+            return originalTarget.closest('table') as HTMLTableElement;
+        }
+    },
+    details: {
+        selector: 'details',
+        content() {
+            return [
+                new EditorButton(detailsEditLabel),
+                new EditorButton(detailsToggle),
+                new EditorButton(detailsUnwrap),
+            ]
+        },
+    },
+};
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts
index d7f02d573..45cb74dd4 100644
--- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts
+++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts
@@ -1,4 +1,3 @@
-import {handleDropdown} from "../helpers/dropdowns";
 import {EditorContainerUiElement, EditorUiElement} from "../core";
 import {EditorBasicButtonDefinition, EditorButton} from "../buttons";
 import {el} from "../../../utils/dom";
@@ -8,6 +7,7 @@ export type EditorDropdownButtonOptions = {
     showOnHover?: boolean;
     direction?: 'vertical'|'horizontal';
     showAside?: boolean;
+    hideOnAction?: boolean;
     button: EditorBasicButtonDefinition|EditorButton;
 };
 
@@ -15,6 +15,7 @@ const defaultOptions: EditorDropdownButtonOptions = {
     showOnHover: false,
     direction: 'horizontal',
     showAside: undefined,
+    hideOnAction: true,
     button: {label: 'Menu'},
 }
 
@@ -40,7 +41,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
                 },
                 isActive: () => {
                     return this.open;
-                }
+                },
             });
         }
 
@@ -65,7 +66,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
             class: 'editor-dropdown-menu-container',
         }, [button, menu]);
 
-        handleDropdown({toggle: button, menu : menu,
+        this.getContext().manager.dropdowns.handle({toggle: button, menu : menu,
             showOnHover: this.options.showOnHover,
             showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'),
             onOpen : () => {
@@ -76,6 +77,12 @@ export class EditorDropdownButton extends EditorContainerUiElement {
             this.getContext().manager.triggerStateUpdateForElement(this.button);
         }});
 
+        if (this.options.hideOnAction) {
+            this.onEvent('button-action', () => {
+                this.getContext().manager.dropdowns.closeAll();
+            }, wrapper);
+        }
+
         return wrapper;
     }
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts
index d666954bf..5d6294935 100644
--- a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts
+++ b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts
@@ -1,6 +1,5 @@
 import {EditorUiStateUpdate, EditorContainerUiElement} from "../core";
 import {EditorButton} from "../buttons";
-import {handleDropdown} from "../helpers/dropdowns";
 import {el} from "../../../utils/dom";
 
 export class EditorFormatMenu extends EditorContainerUiElement {
@@ -20,7 +19,11 @@ export class EditorFormatMenu extends EditorContainerUiElement {
             class: 'editor-format-menu editor-dropdown-menu-container',
         }, [toggle, menu]);
 
-        handleDropdown({toggle : toggle, menu : menu});
+        this.getContext().manager.dropdowns.handle({toggle : toggle, menu : menu});
+
+        this.onEvent('button-action', () => {
+            this.getContext().manager.dropdowns.closeAll();
+        }, wrapper);
 
         return wrapper;
     }
diff --git a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts
index cd0780534..3b582eee6 100644
--- a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts
+++ b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts
@@ -9,9 +9,11 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
     protected size: number;
     protected overflowButton: EditorDropdownButton;
     protected content: EditorUiElement[];
+    protected label: string;
 
-    constructor(size: number, children: EditorUiElement[]) {
+    constructor(label: string, size: number, children: EditorUiElement[]) {
         super(children);
+        this.label = label;
         this.size = size;
         this.content = children;
         this.overflowButton = new EditorDropdownButton({
@@ -19,10 +21,16 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
                 label: 'More',
                 icon: moreHorizontal,
             },
+            hideOnAction: false,
         }, []);
         this.addChildren(this.overflowButton);
     }
 
+    addChild(child: EditorUiElement, targetIndex: number = -1): void {
+        this.content.splice(targetIndex, 0, child);
+        this.addChildren(child);
+    }
+
     protected buildDOM(): HTMLElement {
         const slicePosition = this.content.length > this.size ? this.size - 1 : this.size;
         const visibleChildren = this.content.slice(0, slicePosition);
@@ -40,5 +48,8 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
         }, visibleElements);
     }
 
+    getLabel(): string {
+        return this.label;
+    }
 
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts
index cf114aa02..e12348e81 100644
--- a/resources/js/wysiwyg/ui/framework/buttons.ts
+++ b/resources/js/wysiwyg/ui/framework/buttons.ts
@@ -10,7 +10,12 @@ export interface EditorBasicButtonDefinition {
 }
 
 export interface EditorButtonDefinition extends EditorBasicButtonDefinition {
-    action: (context: EditorUiContext, button: EditorButton) => void;
+    /**
+     * The action to perform when the button is used.
+     * This can return false to indicate that the completion of the action should
+     * NOT be communicated to parent UI elements, which is what occurs by default.
+     */
+    action: (context: EditorUiContext, button: EditorButton) => void|false|Promise<void|boolean>;
     isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean;
     isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean;
     setup?: (context: EditorUiContext, button: EditorButton) => void;
@@ -78,7 +83,16 @@ export class EditorButton extends EditorUiElement {
     }
 
     protected onClick() {
-        this.definition.action(this.getContext(), this);
+        const result = this.definition.action(this.getContext(), this);
+        if (result instanceof Promise) {
+            result.then(result => {
+                if (result === false) {
+                    this.emitEvent('button-action');
+                }
+            });
+        } else if (result !== false) {
+            this.emitEvent('button-action');
+        }
     }
 
     protected updateActiveState(selection: BaseSelection|null) {
diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts
index 90ce4ebf9..9c524dff0 100644
--- a/resources/js/wysiwyg/ui/framework/core.ts
+++ b/resources/js/wysiwyg/ui/framework/core.ts
@@ -30,6 +30,7 @@ export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDef
 export abstract class EditorUiElement {
     protected dom: HTMLElement|null = null;
     private context: EditorUiContext|null = null;
+    private abortController: AbortController = new AbortController();
 
     protected abstract buildDOM(): HTMLElement;
 
@@ -67,6 +68,28 @@ export abstract class EditorUiElement {
     updateState(state: EditorUiStateUpdate): void {
         return;
     }
+
+    emitEvent(name: string, data: object = {}): void {
+        if (this.dom) {
+            this.dom.dispatchEvent(new CustomEvent('editor::' + name, {detail: data, bubbles: true}));
+        }
+    }
+
+    onEvent(name: string, callback: (data: object) => any, listenTarget: HTMLElement|null = null): void {
+        const target = listenTarget || this.dom;
+        if (target) {
+            target.addEventListener('editor::' + name, ((event: CustomEvent) => {
+                callback(event.detail);
+            }) as EventListener, { signal: this.abortController.signal });
+        }
+    }
+
+    teardown(): void {
+        if (this.dom && this.dom.isConnected) {
+            this.dom.remove();
+        }
+        this.abortController.abort('teardown');
+    }
 }
 
 export class EditorContainerUiElement extends EditorUiElement {
@@ -114,6 +137,13 @@ export class EditorContainerUiElement extends EditorUiElement {
             child.setContext(context);
         }
     }
+
+    teardown() {
+        for (const child of this.children) {
+            child.teardown();
+        }
+        super.teardown();
+    }
 }
 
 export class EditorSimpleClassContainer extends EditorContainerUiElement {
diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts
index 570b8222b..6ea0b8b39 100644
--- a/resources/js/wysiwyg/ui/framework/decorator.ts
+++ b/resources/js/wysiwyg/ui/framework/decorator.ts
@@ -48,7 +48,7 @@ export abstract class EditorDecorator {
      * Destroy this decorator. Used for tear-down operations upon destruction
      * of the underlying node this decorator is attached to.
      */
-    destroy(context: EditorUiContext): void {
+    teardown(): void {
         for (const callback of this.onDestroyCallbacks) {
             callback();
         }
diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts
index 08edb214e..b12d9f692 100644
--- a/resources/js/wysiwyg/ui/framework/forms.ts
+++ b/resources/js/wysiwyg/ui/framework/forms.ts
@@ -11,7 +11,7 @@ import {el} from "../../utils/dom";
 export interface EditorFormFieldDefinition {
     label: string;
     name: string;
-    type: 'text' | 'select' | 'textarea' | 'checkbox';
+    type: 'text' | 'select' | 'textarea' | 'checkbox' | 'hidden';
 }
 
 export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition {
@@ -67,6 +67,9 @@ export class EditorFormField extends EditorUiElement {
             input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'});
         } else if (this.definition.type === 'checkbox') {
             input = el('input', {id, name: this.definition.name, type: 'checkbox', class: 'editor-form-field-input-checkbox', value: 'true'});
+        } else if (this.definition.type === 'hidden') {
+            input = el('input', {id, name: this.definition.name, type: 'hidden'});
+            return el('div', {hidden: 'true'}, [input]);
         } else {
             input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'});
         }
diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts
index ccced6858..890d5b325 100644
--- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts
+++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts
@@ -34,57 +34,104 @@ function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean
     }
 }
 
-export function handleDropdown(options: HandleDropdownParams) {
-    const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options;
-    let clickListener: Function|null = null;
+export class DropDownManager {
 
-    const hide = () => {
+    protected dropdownOptions: WeakMap<HTMLElement, HandleDropdownParams> = new WeakMap();
+    protected openDropdowns: Set<HTMLElement> = new Set();
+
+    constructor() {
+        this.onMenuMouseOver = this.onMenuMouseOver.bind(this);
+        this.onWindowClick = this.onWindowClick.bind(this);
+
+        window.addEventListener('click', this.onWindowClick);
+    }
+
+    teardown(): void {
+        window.removeEventListener('click', this.onWindowClick);
+    }
+
+    protected onWindowClick(event: MouseEvent): void {
+        const target = event.target as HTMLElement;
+        this.closeAllNotContainingElement(target);
+    }
+
+    protected closeAllNotContainingElement(element: HTMLElement): void {
+        for (const menu of this.openDropdowns) {
+            if (!menu.parentElement?.contains(element)) {
+                this.closeDropdown(menu);
+            }
+        }
+    }
+
+    protected onMenuMouseOver(event: MouseEvent): void {
+        const target = event.target as HTMLElement;
+        this.closeAllNotContainingElement(target);
+    }
+
+    /**
+     * Close all open dropdowns.
+     */
+    public closeAll(): void {
+        for (const menu of this.openDropdowns) {
+            this.closeDropdown(menu);
+        }
+    }
+
+    protected closeDropdown(menu: HTMLElement): void {
         menu.hidden = true;
         menu.style.removeProperty('position');
         menu.style.removeProperty('left');
         menu.style.removeProperty('top');
-        if (clickListener) {
-            window.removeEventListener('click', clickListener as EventListener);
-        }
+
+        this.openDropdowns.delete(menu);
+        menu.removeEventListener('mouseover', this.onMenuMouseOver);
+
+        const onClose = this.getOptions(menu).onClose;
         if (onClose) {
             onClose();
         }
-    };
+    }
 
-    const show = () => {
+    protected openDropdown(menu: HTMLElement): void {
+        const {toggle, showAside, onOpen} = this.getOptions(menu);
         menu.hidden = false
         positionMenu(menu, toggle, Boolean(showAside));
-        clickListener = (event: MouseEvent) => {
-            if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) {
-                hide();
-            }
-        }
-        window.addEventListener('click', clickListener as EventListener);
+
+        this.openDropdowns.add(menu);
+        menu.addEventListener('mouseover', this.onMenuMouseOver);
+
         if (onOpen) {
             onOpen();
         }
-    };
-
-    const toggleShowing = (event: MouseEvent) => {
-        menu.hasAttribute('hidden') ? show() : hide();
-    };
-    toggle.addEventListener('click', toggleShowing);
-    if (showOnHover) {
-        toggle.addEventListener('mouseenter', toggleShowing);
     }
 
-    menu.parentElement?.addEventListener('mouseleave', (event: MouseEvent) => {
+    protected getOptions(menu: HTMLElement): HandleDropdownParams {
+        const options = this.dropdownOptions.get(menu);
+        if (!options) {
+            throw new Error(`Can't find options for dropdown menu`);
+        }
 
-        // Prevent mouseleave hiding if withing the same bounds of the toggle.
-        // Avoids hiding in the event the mouse is interrupted by a high z-index
-        // item like a browser scrollbar.
-        const toggleBounds = toggle.getBoundingClientRect();
-        const withinX = event.clientX <= toggleBounds.right && event.clientX >= toggleBounds.left;
-        const withinY = event.clientY <= toggleBounds.bottom && event.clientY >= toggleBounds.top;
-        const withinToggle = withinX && withinY;
+        return options;
+    }
 
-        if (!withinToggle) {
-            hide();
+    /**
+     * Add handling for a new dropdown.
+     */
+     public handle(options: HandleDropdownParams) {
+        const {menu, toggle, showOnHover} = options;
+
+        // Register dropdown
+        this.dropdownOptions.set(menu, options);
+
+        // Configure default events
+        const toggleShowing = (event: MouseEvent) => {
+            menu.hasAttribute('hidden') ? this.openDropdown(menu) : this.closeDropdown(menu);
+        };
+        toggle.addEventListener('click', toggleShowing);
+        if (showOnHover) {
+            toggle.addEventListener('mouseenter', () => {
+                this.openDropdown(menu);
+            });
         }
-    });
+    }
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts
index fa8ff48be..8b0a1d2d7 100644
--- a/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts
+++ b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts
@@ -12,17 +12,21 @@ function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode {
 
 class NodeResizer {
     protected context: EditorUiContext;
-    protected dom: HTMLElement|null = null;
+    protected resizerDOM: HTMLElement|null = null;
+    protected targetNode: LexicalNode|null = null;
     protected scrollContainer: HTMLElement;
 
     protected mouseTracker: MouseDragTracker|null = null;
     protected activeSelection: string = '';
+    protected loadAbortController = new AbortController();
 
     constructor(context: EditorUiContext) {
         this.context = context;
         this.scrollContainer = context.scrollDOM;
 
         this.onSelectionChange = this.onSelectionChange.bind(this);
+        this.onTargetDOMLoad = this.onTargetDOMLoad.bind(this);
+
         context.manager.onSelectionChange(this.onSelectionChange);
     }
 
@@ -34,12 +38,7 @@ class NodeResizer {
 
         if (nodes.length === 1 && isNodeWithSize(nodes[0])) {
             const node = nodes[0];
-            const nodeKey = node.getKey();
-            let nodeDOM = this.context.editor.getElementByKey(nodeKey);
-
-            if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
-                nodeDOM = nodeDOM.firstElementChild as HTMLElement;
-            }
+            let nodeDOM = this.getTargetDOM(node)
 
             if (nodeDOM) {
                 this.showForNode(node, nodeDOM);
@@ -47,56 +46,81 @@ class NodeResizer {
         }
     }
 
+    protected getTargetDOM(targetNode: LexicalNode|null): HTMLElement|null {
+        if (targetNode == null) {
+            return null;
+        }
+
+        let nodeDOM =  this.context.editor.getElementByKey(targetNode.__key)
+        if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
+            nodeDOM = nodeDOM.firstElementChild as HTMLElement;
+        }
+        return nodeDOM;
+    }
+
+    protected onTargetDOMLoad(): void {
+        this.updateResizerPosition();
+    }
+
     teardown() {
         this.context.manager.offSelectionChange(this.onSelectionChange);
         this.hide();
     }
 
-    protected showForNode(node: NodeHasSize&LexicalNode, dom: HTMLElement) {
-        this.dom = this.buildDOM();
+    protected showForNode(node: NodeHasSize&LexicalNode, targetDOM: HTMLElement) {
+        this.resizerDOM = this.buildDOM();
+        this.targetNode = node;
 
         let ghost = el('span', {class: 'editor-node-resizer-ghost'});
         if ($isImageNode(node)) {
-            ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-node-resizer-ghost'});
+            ghost = el('img', {src: targetDOM.getAttribute('src'), class: 'editor-node-resizer-ghost'});
         }
-        this.dom.append(ghost);
+        this.resizerDOM.append(ghost);
 
-        this.context.scrollDOM.append(this.dom);
-        this.updateDOMPosition(dom);
+        this.context.scrollDOM.append(this.resizerDOM);
+        this.updateResizerPosition();
 
-        this.mouseTracker = this.setupTracker(this.dom, node, dom);
+        this.mouseTracker = this.setupTracker(this.resizerDOM, node, targetDOM);
         this.activeSelection = node.getKey();
+
+        if (targetDOM.matches('img, embed, iframe, object')) {
+            this.loadAbortController = new AbortController();
+            targetDOM.addEventListener('load', this.onTargetDOMLoad, { signal: this.loadAbortController.signal });
+        }
     }
 
-    protected updateDOMPosition(nodeDOM: HTMLElement) {
-        if (!this.dom) {
+    protected updateResizerPosition() {
+        const targetDOM = this.getTargetDOM(this.targetNode);
+        if (!this.resizerDOM || !targetDOM) {
             return;
         }
 
         const scrollAreaRect = this.scrollContainer.getBoundingClientRect();
-        const nodeRect = nodeDOM.getBoundingClientRect();
+        const nodeRect = targetDOM.getBoundingClientRect();
         const top = nodeRect.top - (scrollAreaRect.top - this.scrollContainer.scrollTop);
         const left = nodeRect.left - scrollAreaRect.left;
 
-        this.dom.style.top = `${top}px`;
-        this.dom.style.left = `${left}px`;
-        this.dom.style.width = nodeRect.width + 'px';
-        this.dom.style.height = nodeRect.height + 'px';
+        this.resizerDOM.style.top = `${top}px`;
+        this.resizerDOM.style.left = `${left}px`;
+        this.resizerDOM.style.width = nodeRect.width + 'px';
+        this.resizerDOM.style.height = nodeRect.height + 'px';
     }
 
     protected updateDOMSize(width: number, height: number): void {
-        if (!this.dom) {
+        if (!this.resizerDOM) {
             return;
         }
 
-        this.dom.style.width = width + 'px';
-        this.dom.style.height = height + 'px';
+        this.resizerDOM.style.width = width + 'px';
+        this.resizerDOM.style.height = height + 'px';
     }
 
     protected hide() {
         this.mouseTracker?.teardown();
-        this.dom?.remove();
+        this.resizerDOM?.remove();
+        this.targetNode = null;
         this.activeSelection = '';
+        this.loadAbortController.abort();
     }
 
     protected buildDOM() {
@@ -110,7 +134,7 @@ class NodeResizer {
         }, handleElems);
     }
 
-    setupTracker(container: HTMLElement, node: NodeHasSize, nodeDOM: HTMLElement): MouseDragTracker {
+    setupTracker(container: HTMLElement, node: NodeHasSize&LexicalNode, nodeDOM: HTMLElement): MouseDragTracker {
         let startingWidth: number = 0;
         let startingHeight: number = 0;
         let startingRatio: number = 0;
@@ -140,7 +164,7 @@ class NodeResizer {
 
         return new MouseDragTracker(container, '.editor-node-resizer-handle', {
             down(event: MouseEvent, handle: HTMLElement) {
-                _this.dom?.classList.add('active');
+                _this.resizerDOM?.classList.add('active');
                 _this.context.editor.getEditorState().read(() => {
                     const domRect = nodeDOM.getBoundingClientRect();
                     startingWidth = node.getWidth() || domRect.width;
@@ -163,12 +187,15 @@ class NodeResizer {
                 _this.context.editor.update(() => {
                     node.setWidth(size.width);
                     node.setHeight(hasHeight ? size.height : 0);
-                    _this.context.manager.triggerLayoutUpdate();
-                    requestAnimationFrame(() => {
-                        _this.updateDOMPosition(nodeDOM);
-                    })
+                }, {
+                    onUpdate: () => {
+                        requestAnimationFrame(() => {
+                            _this.context.manager.triggerLayoutUpdate();
+                            _this.updateResizerPosition();
+                        });
+                    }
                 });
-                _this.dom?.classList.remove('active');
+                _this.resizerDOM?.classList.remove('active');
             }
         });
     }
diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts
index 4256fdafc..ef1d3cb5f 100644
--- a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts
+++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts
@@ -15,6 +15,7 @@ class TableResizer {
     protected targetCell: HTMLElement|null = null;
     protected xMarkerAtStart : boolean = false;
     protected yMarkerAtStart : boolean = false;
+    protected activeInTable: boolean = false;
 
     constructor(editor: LexicalEditor, editScrollContainer: HTMLElement) {
         this.editor = editor;
@@ -33,9 +34,10 @@ class TableResizer {
     }
 
     protected setupListeners() {
+        this.onTableMouseOver = this.onTableMouseOver.bind(this);
         this.onCellMouseMove = this.onCellMouseMove.bind(this);
         this.onScrollOrResize = this.onScrollOrResize.bind(this);
-        this.editScrollContainer.addEventListener('mousemove', this.onCellMouseMove);
+        this.editScrollContainer.addEventListener('mouseover', this.onTableMouseOver, { passive: true });
         window.addEventListener('scroll', this.onScrollOrResize, {capture: true, passive: true});
         window.addEventListener('resize', this.onScrollOrResize, {passive: true});
     }
@@ -44,8 +46,26 @@ class TableResizer {
         this.updateCurrentMarkerTargetPosition();
     }
 
+    protected onTableMouseOver(event: MouseEvent): void {
+        if (this.dragging) {
+            return;
+        }
+
+        const table = (event.target as HTMLElement).closest('table') as HTMLElement|null;
+
+        if (table && !this.activeInTable) {
+            this.editScrollContainer.addEventListener('mousemove', this.onCellMouseMove, { passive: true });
+            this.onCellMouseMove(event);
+            this.activeInTable = true;
+        } else if (!table && this.activeInTable) {
+            this.editScrollContainer.removeEventListener('mousemove', this.onCellMouseMove);
+            this.hideMarkers();
+            this.activeInTable = false;
+        }
+    }
+
     protected onCellMouseMove(event: MouseEvent) {
-        const cell = (event.target as HTMLElement).closest('td,th') as HTMLElement;
+        const cell = (event.target as HTMLElement).closest('td,th') as HTMLElement|null;
         if (!cell || this.dragging) {
             return;
         }
@@ -66,10 +86,16 @@ class TableResizer {
     protected updateMarkersTo(cell: HTMLElement, xPos: number, yPos: number) {
         const markers: MarkerDomRecord = this.getMarkers();
         const table = cell.closest('table') as HTMLElement;
+        const caption: HTMLTableCaptionElement|null = table.querySelector('caption');
         const tableRect = table.getBoundingClientRect();
         const editBounds = this.editScrollContainer.getBoundingClientRect();
 
-        const maxTop = Math.max(tableRect.top, editBounds.top);
+        let tableTop = tableRect.top;
+        if (caption) {
+            tableTop = caption.getBoundingClientRect().bottom;
+        }
+
+        const maxTop = Math.max(tableTop, editBounds.top);
         const maxBottom = Math.min(tableRect.bottom, editBounds.bottom);
         const maxHeight = maxBottom - maxTop;
         markers.x.style.left = xPos + 'px';
@@ -85,6 +111,13 @@ class TableResizer {
         markers.x.hidden = tableRect.top > editBounds.bottom || tableRect.bottom < editBounds.top;
     }
 
+    protected hideMarkers(): void {
+        if (this.markerDom) {
+            this.markerDom.x.hidden = true;
+            this.markerDom.y.hidden = true;
+        }
+    }
+
     protected updateCurrentMarkerTargetPosition(): void {
         if (!this.targetCell) {
             return;
diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts
index d3d892550..c05e448f5 100644
--- a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts
+++ b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts
@@ -56,7 +56,7 @@ class TableSelectionHandler {
                 tableNode,
                 tableElement,
                 this.editor,
-                false,
+                true,
             );
             this.tableSelections.set(nodeKey, tableSelection);
         }
diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts
index 0f501d9fa..1adc0b619 100644
--- a/resources/js/wysiwyg/ui/framework/manager.ts
+++ b/resources/js/wysiwyg/ui/framework/manager.ts
@@ -6,11 +6,14 @@ import {DecoratorListener} from "lexical/LexicalEditor";
 import type {NodeKey} from "lexical/LexicalNode";
 import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
 import {getLastSelection, setLastSelection} from "../../utils/selection";
+import {DropDownManager} from "./helpers/dropdowns";
 
 export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
 
 export class EditorUIManager {
 
+    public dropdowns: DropDownManager = new DropDownManager();
+
     protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
     protected activeModalsByKey: Record<string, EditorFormModal> = {};
     protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
@@ -20,10 +23,12 @@ export class EditorUIManager {
     protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
     protected activeContextToolbars: EditorContextToolbar[] = [];
     protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
+    protected domEventAbortController = new AbortController();
+    protected teardownCallbacks: (()=>void)[] = [];
 
     setContext(context: EditorUiContext) {
         this.context = context;
-        this.setupEventListeners(context);
+        this.setupEventListeners();
         this.setupEditor(context.editor);
     }
 
@@ -96,7 +101,7 @@ export class EditorUIManager {
 
     setToolbar(toolbar: EditorContainerUiElement) {
         if (this.toolbar) {
-            this.toolbar.getDOMElement().remove();
+            this.toolbar.teardown();
         }
 
         this.toolbar = toolbar;
@@ -104,6 +109,10 @@ export class EditorUIManager {
         this.getContext().containerDOM.prepend(toolbar.getDOMElement());
     }
 
+    getToolbar(): EditorContainerUiElement|null {
+        return this.toolbar;
+    }
+
     registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
         this.contextToolbarDefinitionsByKey[key] = definition;
     }
@@ -167,10 +176,40 @@ export class EditorUIManager {
         return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr';
     }
 
+    onTeardown(callback: () => void): void {
+        this.teardownCallbacks.push(callback);
+    }
+
+    teardown(): void {
+        this.domEventAbortController.abort('teardown');
+
+        for (const [_, modal] of Object.entries(this.activeModalsByKey)) {
+            modal.teardown();
+        }
+
+        for (const [_, decorator] of Object.entries(this.decoratorInstancesByNodeKey)) {
+            decorator.teardown();
+        }
+
+        if (this.toolbar) {
+            this.toolbar.teardown();
+        }
+
+        for (const toolbar of this.activeContextToolbars) {
+            toolbar.teardown();
+        }
+
+        this.dropdowns.teardown();
+
+        for (const callback of this.teardownCallbacks) {
+            callback();
+        }
+    }
+
     protected updateContextToolbars(update: EditorUiStateUpdate): void {
         for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
             const toolbar = this.activeContextToolbars[i];
-            toolbar.destroy();
+            toolbar.teardown();
             this.activeContextToolbars.splice(i, 1);
         }
 
@@ -195,7 +234,7 @@ export class EditorUIManager {
                     contentByTarget.set(targetEl, [])
                 }
                 // @ts-ignore
-                contentByTarget.get(targetEl).push(...definition.content);
+                contentByTarget.get(targetEl).push(...definition.content());
             }
         }
 
@@ -241,6 +280,7 @@ export class EditorUIManager {
             if (selectionChange) {
                 editor.update(() => {
                     const selection = $getSelection();
+                    // console.log('manager::selection', selection);
                     this.triggerStateUpdate({
                         editor, selection,
                     });
@@ -249,9 +289,9 @@ export class EditorUIManager {
         });
     }
 
-    protected setupEventListeners(context: EditorUiContext) {
+    protected setupEventListeners() {
         const layoutUpdate = this.triggerLayoutUpdate.bind(this);
-        window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true});
-        window.addEventListener('resize', layoutUpdate, {passive: true});
+        window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true, signal: this.domEventAbortController.signal});
+        window.addEventListener('resize', layoutUpdate, {passive: true, signal: this.domEventAbortController.signal});
     }
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts
index 3eea62ebb..4dbe9d962 100644
--- a/resources/js/wysiwyg/ui/framework/modals.ts
+++ b/resources/js/wysiwyg/ui/framework/modals.ts
@@ -34,8 +34,8 @@ export class EditorFormModal extends EditorContainerUiElement {
     }
 
     hide() {
-        this.getDOMElement().remove();
         this.getContext().manager.setModalInactive(this.key);
+        this.teardown();
     }
 
     getForm(): EditorForm {
diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts
index b4e49af95..cf5ec4ad1 100644
--- a/resources/js/wysiwyg/ui/framework/toolbars.ts
+++ b/resources/js/wysiwyg/ui/framework/toolbars.ts
@@ -4,7 +4,7 @@ import {el} from "../../utils/dom";
 
 export type EditorContextToolbarDefinition = {
     selector: string;
-    content: EditorUiElement[],
+    content: () => EditorUiElement[],
     displayTargetLocator?: (originalTarget: HTMLElement) => HTMLElement;
 };
 
@@ -34,7 +34,11 @@ export class EditorContextToolbar extends EditorContainerUiElement {
 
         dom.hidden = !showing;
 
-        if (!showing) {
+        if (!this.target.isConnected) {
+            // If our target is no longer in the DOM, tell the manager an update is needed.
+            this.getContext().manager.triggerFutureStateRefresh();
+            return;
+        } else if (!showing) {
             return;
         }
 
@@ -56,17 +60,4 @@ export class EditorContextToolbar extends EditorContainerUiElement {
         const dom = this.getDOMElement();
         dom.append(...children.map(child => child.getDOMElement()));
     }
-
-    protected empty() {
-        const children = this.getChildren();
-        for (const child of children) {
-            child.getDOMElement().remove();
-        }
-        this.removeChildren(...children);
-    }
-
-    destroy() {
-        this.empty();
-        this.getDOMElement().remove();
-    }
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts
index fda37085e..c48386bb4 100644
--- a/resources/js/wysiwyg/ui/index.ts
+++ b/resources/js/wysiwyg/ui/index.ts
@@ -1,23 +1,30 @@
 import {LexicalEditor} from "lexical";
-import {
-    getCodeToolbarContent, getDetailsToolbarContent,
-    getImageToolbarContent,
-    getLinkToolbarContent,
-    getMainEditorFullToolbar, getTableToolbarContent
-} from "./defaults/toolbars";
 import {EditorUIManager} from "./framework/manager";
 import {EditorUiContext} from "./framework/core";
-import {CodeBlockDecorator} from "./decorators/code-block";
-import {DiagramDecorator} from "./decorators/diagram";
-import {modals} from "./defaults/modals";
+import {el} from "../utils/dom";
+
+export function buildEditorUI(containerDOM: HTMLElement, editor: LexicalEditor, options: Record<string, any>): EditorUiContext {
+    const editorDOM = el('div', {
+        contenteditable: 'true',
+        class: `editor-content-area ${options.editorClass || ''}`,
+    });
+    const scrollDOM = el('div', {
+        class: 'editor-content-wrap',
+    }, [editorDOM]);
+
+    containerDOM.append(scrollDOM);
+    containerDOM.classList.add('editor-container');
+    containerDOM.setAttribute('dir', options.textDirection);
+    if (options.darkMode) {
+        containerDOM.classList.add('editor-dark');
+    }
 
-export function buildEditorUI(container: HTMLElement, element: HTMLElement, scrollContainer: HTMLElement, editor: LexicalEditor, options: Record<string, any>): EditorUiContext {
     const manager = new EditorUIManager();
     const context: EditorUiContext = {
         editor,
-        containerDOM: container,
-        editorDOM: element,
-        scrollDOM: scrollContainer,
+        containerDOM: containerDOM,
+        editorDOM: editorDOM,
+        scrollDOM: scrollDOM,
         manager,
         translate(text: string): string {
             const translations = options.translations;
@@ -31,46 +38,5 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
     };
     manager.setContext(context);
 
-    // Create primary toolbar
-    manager.setToolbar(getMainEditorFullToolbar(context));
-
-    // Register modals
-    for (const key of Object.keys(modals)) {
-        manager.registerModal(key, modals[key]);
-    }
-
-    // Register context toolbars
-    manager.registerContextToolbar('image', {
-        selector: 'img:not([drawio-diagram] img)',
-        content: getImageToolbarContent(),
-    });
-    manager.registerContextToolbar('link', {
-        selector: 'a',
-        content: getLinkToolbarContent(),
-        displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
-            const image = originalTarget.querySelector('img');
-            return image || originalTarget;
-        }
-    });
-    manager.registerContextToolbar('code', {
-        selector: '.editor-code-block-wrap',
-        content: getCodeToolbarContent(),
-    });
-    manager.registerContextToolbar('table', {
-        selector: 'td,th',
-        content: getTableToolbarContent(),
-        displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
-            return originalTarget.closest('table') as HTMLTableElement;
-        }
-    });
-    manager.registerContextToolbar('details', {
-        selector: 'details',
-        content: getDetailsToolbarContent(),
-    });
-
-    // Register image decorator listener
-    manager.registerDecoratorType('code', CodeBlockDecorator);
-    manager.registerDecoratorType('diagram', DiagramDecorator);
-
     return context;
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts
index ae829bae3..e18ac515f 100644
--- a/resources/js/wysiwyg/utils/actions.ts
+++ b/resources/js/wysiwyg/utils/actions.ts
@@ -1,6 +1,6 @@
-import {$getRoot, $getSelection, LexicalEditor} from "lexical";
+import {$getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical";
 import {$generateHtmlFromNodes} from "@lexical/html";
-import {$htmlToBlockNodes} from "./nodes";
+import {$getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes";
 
 export function setEditorContentFromHtml(editor: LexicalEditor, html: string) {
     editor.update(() => {
@@ -42,14 +42,34 @@ export function prependHtmlToEditor(editor: LexicalEditor, html: string) {
 export function insertHtmlIntoEditor(editor: LexicalEditor, html: string) {
     editor.update(() => {
         const selection = $getSelection();
-        const nodes = $htmlToBlockNodes(editor, html);
+        const nodes = $htmlToNodes(editor, html);
+
+        let reference = selection?.getNodes()[0];
+        let replacedReference = false;
+        let parentBlock = reference ? $getNearestNodeBlockParent(reference) : null;
 
-        const reference = selection?.getNodes()[0];
-        const referencesParents = reference?.getParents() || [];
-        const topLevel = referencesParents[referencesParents.length - 1];
-        if (topLevel && reference) {
-            for (let i = nodes.length - 1; i >= 0; i--) {
-                reference.insertAfter(nodes[i]);
+        for (let i = nodes.length - 1; i >= 0; i--) {
+            const toInsert = nodes[i];
+            if ($isBlockElementNode(toInsert) && parentBlock) {
+                // Insert at a block level, before or after the referenced block
+                // depending on if the reference has been replaced.
+                if (replacedReference) {
+                    parentBlock.insertBefore(toInsert);
+                } else {
+                    parentBlock.insertAfter(toInsert);
+                }
+            } else if ($isBlockElementNode(toInsert)) {
+                // Otherwise append blocks to the root
+                $getRoot().append(toInsert);
+            } else if (!replacedReference) {
+                // First inline node, replacing existing selection
+                $insertNodes([toInsert]);
+                reference = toInsert;
+                parentBlock = $getNearestNodeBlockParent(reference);
+                replacedReference = true;
+            } else {
+                // For other inline nodes, insert before the reference node
+                reference?.insertBefore(toInsert)
             }
         }
     });
@@ -64,6 +84,6 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise<string> {
     });
 }
 
-export function focusEditor(editor: LexicalEditor) {
+export function focusEditor(editor: LexicalEditor): void {
     editor.focus(() => {}, {defaultSelection: "rootStart"});
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/utils/details.ts b/resources/js/wysiwyg/utils/details.ts
new file mode 100644
index 000000000..79e8600d0
--- /dev/null
+++ b/resources/js/wysiwyg/utils/details.ts
@@ -0,0 +1,9 @@
+import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+
+export function $unwrapDetailsNode(node: DetailsNode) {
+    const children = node.getChildren();
+    for (const child of children) {
+        node.insertBefore(child);
+    }
+    node.remove();
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/utils/dom.ts b/resources/js/wysiwyg/utils/dom.ts
index bbb07cb41..8bacd1002 100644
--- a/resources/js/wysiwyg/utils/dom.ts
+++ b/resources/js/wysiwyg/utils/dom.ts
@@ -52,12 +52,19 @@ export type StyleMap = Map<string, string>;
 /**
  * Creates a map from an element's styles.
  * Uses direct attribute value string handling since attempting to iterate
- * over .style will expand out any shorthand properties (like 'padding') making
+ * over .style will expand out any shorthand properties (like 'padding')
  * rather than being representative of the actual properties set.
  */
 export function extractStyleMapFromElement(element: HTMLElement): StyleMap {
-    const map: StyleMap = new Map();
     const styleText= element.getAttribute('style') || '';
+    return styleStringToStyleMap(styleText);
+}
+
+/**
+ * Convert string-formatted styles into a StyleMap.
+ */
+export function styleStringToStyleMap(styleText: string): StyleMap {
+    const map: StyleMap = new Map();
 
     const rules = styleText.split(';');
     for (const rule of rules) {
@@ -72,6 +79,17 @@ export function extractStyleMapFromElement(element: HTMLElement): StyleMap {
     return map;
 }
 
+/**
+ * Convert a StyleMap into inline string style text.
+ */
+export function styleMapToStyleString(map: StyleMap): string {
+    const parts = [];
+    for (const [style, value] of map.entries()) {
+        parts.push(`${style}:${value}`);
+    }
+    return parts.join(';');
+}
+
 export function setOrRemoveAttribute(element: HTMLElement, name: string, value: string|null|undefined) {
     if (value) {
         element.setAttribute(name, value);
diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts
index 591232ea3..ed70bf699 100644
--- a/resources/js/wysiwyg/utils/nodes.ts
+++ b/resources/js/wysiwyg/utils/nodes.ts
@@ -6,7 +6,7 @@ import {
     $isTextNode,
     ElementNode,
     LexicalEditor,
-    LexicalNode
+    LexicalNode, RangeSelection
 } from "lexical";
 import {LexicalNodeMatcher} from "../nodes";
 import {$generateNodesFromDOM} from "@lexical/html";
@@ -25,10 +25,13 @@ function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] {
     });
 }
 
-export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] {
+export function $htmlToNodes(editor: LexicalEditor, html: string): LexicalNode[] {
     const dom = htmlToDom(html);
-    const nodes = $generateNodesFromDOM(editor, dom);
-    return wrapTextNodes(nodes);
+    return $generateNodesFromDOM(editor, dom);
+}
+
+export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] {
+    return wrapTextNodes($htmlToNodes(editor, html));
 }
 
 export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher): LexicalNode | null {
@@ -118,6 +121,22 @@ export function $sortNodes(nodes: LexicalNode[]): LexicalNode[] {
     return sorted;
 }
 
+export function $selectOrCreateAdjacent(node: LexicalNode, after: boolean): RangeSelection {
+    const nearestBlock = $getNearestNodeBlockParent(node) || node;
+    let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling()
+
+    if (!target) {
+        target = $createParagraphNode();
+        if (after) {
+            nearestBlock.insertAfter(target)
+        } else {
+            nearestBlock.insertBefore(target);
+        }
+    }
+
+    return after ? target.selectStart() : target.selectEnd();
+}
+
 export function nodeHasAlignment(node: object): node is NodeHasAlignment {
     return '__alignment' in node;
 }
diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts
index 167ab32ad..e4b5bf2dc 100644
--- a/resources/js/wysiwyg/utils/selection.ts
+++ b/resources/js/wysiwyg/utils/selection.ts
@@ -3,7 +3,7 @@ import {
     $createParagraphNode, $createRangeSelection,
     $getRoot,
     $getSelection, $isBlockElementNode, $isDecoratorNode,
-    $isElementNode,
+    $isElementNode, $isParagraphNode,
     $isTextNode,
     $setSelection,
     BaseSelection, DecoratorNode,
@@ -60,12 +60,19 @@ export function $selectionContainsTextFormat(selection: BaseSelection | null, fo
         return false;
     }
 
-    for (const node of selection.getNodes()) {
+    // Check text nodes
+    const nodes = selection.getNodes();
+    for (const node of nodes) {
         if ($isTextNode(node) && node.hasFormat(format)) {
             return true;
         }
     }
 
+    // If we're in an empty paragraph, check the paragraph format
+    if (nodes.length === 1 && $isParagraphNode(nodes[0]) && nodes[0].hasTextFormat(format)) {
+        return true;
+    }
+
     return false;
 }
 
diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts
index ed947ddcd..15cc3cbbe 100644
--- a/resources/js/wysiwyg/utils/tables.ts
+++ b/resources/js/wysiwyg/utils/tables.ts
@@ -9,7 +9,7 @@ import {
 } from "@lexical/table";
 import {$getParentOfType} from "./nodes";
 import {$getNodeFromSelection} from "./selection";
-import {formatSizeValue} from "./dom";
+import {el, formatSizeValue} from "./dom";
 import {TableMap} from "./table-map";
 
 function $getTableFromCell(cell: TableCellNode): TableNode|null {
@@ -140,6 +140,23 @@ export function $getTableCellColumnWidth(editor: LexicalEditor, cell: TableCellN
     return (widths.length > index) ? widths[index] : '';
 }
 
+export function buildColgroupFromTableWidths(colWidths: string[]): HTMLElement|null {
+    if (colWidths.length === 0) {
+        return null
+    }
+
+    const colgroup = el('colgroup');
+    for (const width of colWidths) {
+        const col = el('col');
+        if (width) {
+            col.style.width = width;
+        }
+        colgroup.append(col);
+    }
+
+    return colgroup;
+}
+
 export function $getTableCellsFromSelection(selection: BaseSelection|null): TableCellNode[]  {
     if ($isTableSelection(selection)) {
         const nodes = selection.getNodes();
@@ -265,6 +282,7 @@ export function $clearTableFormatting(table: TableNode): void {
         const cells = row.getChildren().filter(c => $isTableCellNode(c));
         for (const cell of cells) {
             cell.setStyles(new Map);
+            cell.setBackgroundColor(null);
             cell.clearWidth();
         }
     }
diff --git a/resources/sass/_animations.scss b/resources/sass/_animations.scss
index f1aa3139b..ccbe36161 100644
--- a/resources/sass/_animations.scss
+++ b/resources/sass/_animations.scss
@@ -67,4 +67,26 @@
   animation-duration: 180ms;
   animation-delay: 0s;
   animation-timing-function: cubic-bezier(.62, .28, .23, .99);
+}
+
+@keyframes highlight {
+  0% {
+    background-color: var(--color-primary-light);
+  }
+  33% {
+    background-color: transparent;
+  }
+  66% {
+    background-color: var(--color-primary-light);
+  }
+  100% {
+    background-color: transparent;
+  }
+}
+
+.anim-highlight {
+  animation-name: highlight;
+  animation-duration: 2s;
+  animation-delay: 0s;
+  animation-timing-function: linear;
 }
\ No newline at end of file
diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss
index 8c248caee..4857c2cee 100644
--- a/resources/sass/_blocks.scss
+++ b/resources/sass/_blocks.scss
@@ -274,7 +274,19 @@
 
 .sticky-sidebar {
   position: sticky;
-  top: vars.$m;
-  max-height: calc(100vh - #{vars.$m});
+  top: 0;
+  padding-left: 2px;
+  max-height: calc(100vh);
   overflow-y: auto;
+  .sticky-sidebar-header {
+    position: sticky;
+    top: 0;
+    background: #F2F2F2;
+    background: linear-gradient(180deg,rgba(242, 242, 242, 1) 66%, rgba(242, 242, 242, 0) 100%);
+    z-index: 4;
+  }
+}
+.dark-mode .sticky-sidebar-header {
+  background: #111;
+  background: linear-gradient(180deg,rgba(17, 17, 17, 1) 66%, rgba(17, 17, 17, 0) 100%);
 }
diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss
index 58d39d3ee..8ea15de80 100644
--- a/resources/sass/_components.scss
+++ b/resources/sass/_components.scss
@@ -569,6 +569,9 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   border-bottom: 0;
   padding: 0 vars.$xs;
 }
+.tab-container [role="tabpanel"].no-outline:focus {
+  outline: none;
+}
 
 .image-picker .none {
   display: none;
@@ -746,6 +749,52 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   height: calc(100% - vars.$m);
 }
 
+.comment-reference-indicator-wrap a {
+  float: left;
+  margin-top: vars.$xs;
+  font-size: 12px;
+  display: inline-block;
+  font-weight: bold;
+  position: relative;
+  border-radius: 4px;
+  overflow: hidden;
+  padding: 2px 6px 2px 0;
+  margin-inline-end: vars.$xs;
+  color: var(--color-link);
+  span {
+    display: none;
+  }
+  &.outdated span {
+    display: inline;
+  }
+  &.outdated.missing {
+    color: var(--color-warning);
+    pointer-events: none;
+  }
+  svg {
+    width: 24px;
+    margin-inline-end: 0;
+  }
+  &:after {
+    background-color: currentColor;
+    content: '';
+    width: 100%;
+    height: 100%;
+    position: absolute;
+    left: 0;
+    top: 0;
+    opacity: 0.15;
+  }
+  &[href="#"] {
+    color: #444;
+    pointer-events: none;
+  }
+}
+
+.comment-branch .comment-box {
+  margin-bottom: vars.$m;
+}
+
 .comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator {
   display: none;
 }
@@ -760,7 +809,15 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   display: block;
 }
 
+.comment-container .empty-state {
+  display: none;
+}
+.comment-container:not(:has([component="page-comment"])) .empty-state {
+  display: block;
+}
+
 .comment-container-compact .comment-box {
+  margin-bottom: vars.$xs;
   .meta {
     font-size: 0.8rem;
   }
@@ -778,6 +835,29 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   width: vars.$m;
 }
 
+.comment-container-super-compact .comment-box {
+  .meta {
+    font-size: 12px;
+  }
+  .avatar {
+    width: 22px;
+    height: 22px;
+    margin-inline-end: 2px !important;
+  }
+  .content {
+    padding: vars.$xxs vars.$s;
+    line-height: 1.2;
+  }
+  .content p {
+    font-size: 12px;
+  }
+}
+
+.comment-container-super-compact .comment-thread-indicator {
+  width: (vars.$xs + 3px);
+  margin-inline-start: 3px;
+}
+
 #tag-manager .drag-card {
   max-width: 500px;
 }
@@ -824,8 +904,11 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   border-radius: 4px;
   line-height: normal;
   padding: vars.$xs;
+  opacity: 0.6;
+  cursor: pointer;
   &:hover {
-    border-color: #DDD;
+    opacity: 1;
+    @include mixins.lightDark(border-color, #DDD, #444);
   }
   .svg-icon {
     margin-inline-end: 0;
@@ -1127,4 +1210,21 @@ input.scroll-box-search, .scroll-box-header-item {
 }
 .scroll-box > li.empty-state:last-child {
   display: list-item;
+}
+
+details.section-expander summary {
+  border-top: 1px solid #DDD;
+  @include mixins.lightDark(border-color, #DDD, #000);
+  font-weight: bold;
+  font-size: 12px;
+  color: #888;
+  cursor: pointer;
+  padding-block: vars.$xs;
+}
+details.section-expander:open summary {
+  margin-bottom: vars.$s;
+}
+details.section-expander {
+  border-bottom: 1px solid #DDD;
+  @include mixins.lightDark(border-color, #DDD, #000);
 }
\ No newline at end of file
diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss
index b0176d64e..aba1556a9 100644
--- a/resources/sass/_content.scss
+++ b/resources/sass/_content.scss
@@ -11,6 +11,7 @@
   max-width: 840px;
   margin: 0 auto;
   overflow-wrap: break-word;
+  position: relative;
   .align-left {
     text-align: left;
   }
diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss
index 35f11c5a2..cd4bb5d2e 100644
--- a/resources/sass/_editor.scss
+++ b/resources/sass/_editor.scss
@@ -52,6 +52,25 @@ body.editor-is-fullscreen {
   flex: 1;
 }
 
+// Variation specific styles
+.comment-editor-container,
+.basic-editor-container {
+  border-left: 1px solid #DDD;
+  border-right: 1px solid #DDD;
+  border-bottom: 1px solid #DDD;
+  border-radius: 3px;
+  @include mixins.lightDark(border-color, #DDD, #000);
+
+  .editor-toolbar-main {
+    border-radius: 3px 3px 0 0;
+    justify-content: end;
+  }
+}
+
+.basic-editor-container .editor-content-area {
+  padding-bottom: 0;
+}
+
 // Buttons
 .editor-button {
   font-size: 12px;
@@ -418,11 +437,25 @@ body.editor-is-fullscreen {
 .editor-node-resizer.active .editor-node-resizer-ghost {
   display: block;
 }
+.editor-content-area details[contenteditable="false"],
+.editor-content-area summary[contenteditable="false"] {
+  user-select: none;
+}
+.editor-content-area details[contenteditable="false"] > details * {
+  pointer-events: none;
+}
+.editor-content-area details summary {
+  caret-color: transparent;
+}
+.editor-content-area details.selected {
+  outline: 1px dashed var(--editor-color-primary);
+  outline-offset: 1px;
+}
 
 .editor-table-marker {
   position: fixed;
   background-color: var(--editor-color-primary);
-  z-index: 99;
+  z-index: 3;
   user-select: none;
   opacity: 0;
   &:hover, &.active {
@@ -454,7 +487,7 @@ body.editor-is-fullscreen {
 .editor-media-wrap {
   display: inline-block;
   cursor: not-allowed;
-  iframe {
+  iframe, video {
     pointer-events: none;
   }
   &.align-left {
@@ -662,6 +695,14 @@ textarea.editor-form-field-input {
   }
 }
 
+// Specific field styles
+textarea.editor-form-field-input[name="source"] {
+  width: 1000px;
+  height: 600px;
+  max-height: 60vh;
+  max-width: 80vw;
+}
+
 // Editor theme styles
 .editor-theme-bold {
   font-weight: bold;
diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss
index b66688f8d..12fb3385f 100644
--- a/resources/sass/_forms.scss
+++ b/resources/sass/_forms.scss
@@ -55,12 +55,17 @@
     font-style: normal;
     font-weight: 400;
     padding: vars.$xs vars.$m;
-    color: #444;
+    @include mixins.lightDark(color, #444, #aaa);
+    @include mixins.lightDark(background-color, #fff, #222);
     border-radius: 0;
+    height: 100%;
+    font-size: 14px;
+    line-height: 1.2;
     max-height: 100%;
     flex: 1;
     border: 0;
     width: 100%;
+    margin: 0;
     &:focus {
       outline: 0;
     }
diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss
index 3ec7275a4..a23f92d3f 100644
--- a/resources/sass/_header.scss
+++ b/resources/sass/_header.scss
@@ -65,6 +65,7 @@ header {
     margin: 0 (-(vars.$s));
     border-radius: 3px;
     gap: vars.$xs;
+    color: #FFF;
     > span {
       padding-inline-start: vars.$xs;
       display: inline-block;
diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss
index 8175db948..48b4b0ca2 100644
--- a/resources/sass/_layout.scss
+++ b/resources/sass/_layout.scss
@@ -389,10 +389,12 @@ body.flexbox {
 .tri-layout-right {
   grid-area: c;
   min-width: 0;
+  position: relative;
 }
 .tri-layout-left {
   grid-area: a;
   min-width: 0;
+  position: relative;
 }
 
 @include mixins.larger-than(vars.$bp-xxl) {
@@ -431,7 +433,8 @@ body.flexbox {
     grid-template-areas:  "a b b";
     grid-template-columns: 1fr 3fr;
     grid-template-rows: min-content min-content 1fr;
-    padding-inline-end: vars.$l;
+    margin-inline-start: (vars.$m + vars.$xxs);
+    margin-inline-end: (vars.$m + vars.$xxs);
   }
   .tri-layout-sides {
     grid-column-start: a;
@@ -452,6 +455,8 @@ body.flexbox {
     height: 100%;
     scrollbar-width: none;
     -ms-overflow-style: none;
+    padding-inline: vars.$m;
+    margin-inline: -(vars.$m);
     &::-webkit-scrollbar {
       display: none;
     }
@@ -520,4 +525,26 @@ body.flexbox {
     margin-inline-start: 0;
     margin-inline-end: 0;
   }
+}
+
+/**
+ * Scroll Indicators
+ */
+.scroll-away-from-top:before,
+.scroll-away-from-bottom:after {
+  content: '';
+  display: block;
+  position: absolute;
+  @include mixins.lightDark(color, #F2F2F2, #111);
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 50px;
+  background: linear-gradient(to bottom, currentColor, transparent);
+  z-index: 2;
+}
+.scroll-away-from-bottom:after {
+  top: auto;
+  bottom: 0;
+  background: linear-gradient(to top, currentColor, transparent);
 }
\ No newline at end of file
diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss
index 45e58ffc8..83aec46f0 100755
--- a/resources/sass/_pages.scss
+++ b/resources/sass/_pages.scss
@@ -158,11 +158,7 @@ body.tox-fullscreen, body.markdown-fullscreen {
   border-radius: 4px;
   box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1);
   @include mixins.lightDark(background-color, #fff, #333);
-  width: 275px;
-
-  &.is-page-editable {
-    width: 328px;
-  }
+  width: 328px;
 
   &:before {
     position: absolute;
@@ -183,7 +179,6 @@ body.tox-fullscreen, body.markdown-fullscreen {
   }
   input, button, a {
     position: relative;
-    border-radius: 0;
     height: 28px;
     font-size: 12px;
     vertical-align: top;
@@ -194,17 +189,21 @@ body.tox-fullscreen, body.markdown-fullscreen {
     border: 1px solid #DDD;
     @include mixins.lightDark(border-color, #ddd, #000);
     color: #666;
-    width: 160px;
-    z-index: 40;
-    padding: 5px 10px;
+    width: auto;
+    flex: 1;
+    z-index: 58;
+    padding: 5px;
+    border-radius: 0;
   }
   .text-button {
     @include mixins.lightDark(color, #444, #AAA);
   }
   .input-group .button {
     line-height: 1;
-    margin: 0 0 0 -4px;
+    margin-inline-start: -1px;
+    margin-block: 0;
     box-shadow: none;
+    border-radius: 0;
   }
   a.button {
     margin: 0;
@@ -218,6 +217,97 @@ body.tox-fullscreen, body.markdown-fullscreen {
   }
 }
 
+// Page inline comments
+.content-comment-highlight {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 0;
+  height: 0;
+  user-select: none;
+  pointer-events: none;
+  &:after {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    background-color: var(--color-primary);
+    opacity: 0.25;
+  }
+}
+.content-comment-window {
+  font-size: vars.$fs-m;
+  line-height: 1.4;
+  position: absolute;
+  top: calc(100% + 3px);
+  left: 0;
+  z-index: 92;
+  pointer-events: all;
+  min-width: min(340px, 80vw);
+  @include mixins.lightDark(background-color, #FFF, #222);
+  box-shadow: vars.$bs-hover;
+  border-radius: 4px;
+  overflow: hidden;
+}
+.content-comment-window-actions {
+  background-color: var(--color-primary);
+  color: #FFF;
+  display: flex;
+  align-items: center;
+  justify-content: end;
+  gap: vars.$xs;
+  button {
+    color: #FFF;
+    font-size: 12px;
+    padding: vars.$xs;
+    line-height: 1;
+    cursor: pointer;
+  }
+  button[data-action="jump"] {
+    text-decoration: underline;
+  }
+  svg {
+    fill: currentColor;
+    width: 12px;
+  }
+}
+.content-comment-window-content {
+  padding: vars.$xs vars.$s vars.$xs vars.$xs;
+  max-height: 200px;
+  overflow-y: scroll;
+}
+.content-comment-window-content .comment-reference-indicator-wrap {
+  display: none;
+}
+.content-comment-marker {
+  position: absolute;
+  right: -16px;
+  top: -16px;
+  pointer-events: all;
+  width: min(1.5em, 32px);
+  height: min(1.5em, 32px);
+  border-radius: min(calc(1.5em / 2), 32px);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: var(--color-primary);
+  box-shadow: vars.$bs-hover;
+  color: #FFF;
+  cursor: pointer;
+  z-index: 90;
+  transform: scale(1);
+  transition: transform ease-in-out 120ms;
+  svg {
+    fill: #FFF;
+    width: 80%;
+  }
+}
+.page-content [id^="bkmrk-"]:hover .content-comment-marker {
+  transform: scale(1.15);
+}
+
 // Page editor sidebar toolbox
 .floating-toolbox {
   @include mixins.lightDark(background-color, #FFF, #222);
diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss
index c6642d0ca..4bae5da66 100644
--- a/resources/sass/styles.scss
+++ b/resources/sass/styles.scss
@@ -232,13 +232,15 @@ $loadingSize: 10px;
   .list-sort-label {
     font-weight: bold;
     display: inline-block;
+  }
+  .list-sort-label, .list-sort-toggle {
     @include mixins.lightDark(color, #555, #888);
   }
   .list-sort-type {
     text-align: start;
   }
-  .list-sort-type, .list-sort-dir {
-    padding: vars.$xs vars.$s;
+  .list-sort-toggle, .list-sort-dir {
+    padding: (vars.$xs + 2) vars.$s;
     cursor: pointer;
   }
   .list-sort-dir {
@@ -252,6 +254,11 @@ $loadingSize: 10px;
       transform: rotate(180deg);
     }
   }
+  .list-sort-toggle {
+    display: block;
+    width: 100%;
+    text-align: start;
+  }
 }
 
 .import-item {
diff --git a/resources/views/api-docs/index.blade.php b/resources/views/api-docs/index.blade.php
index 9345a7bce..c331f0707 100644
--- a/resources/views/api-docs/index.blade.php
+++ b/resources/views/api-docs/index.blade.php
@@ -2,50 +2,69 @@
 
 @section('body')
 
-    <div class="container pt-xl">
+    <div component="api-nav" class="container">
 
         <div class="grid right-focus reverse-collapse">
             <div>
 
-                <div class="sticky-sidebar">
-                    <p class="text-uppercase text-muted mb-xm mt-l"><strong>Getting Started</strong></p>
+                <div refs="api-nav@sidebar" class="sticky-sidebar">
 
-                    <div class="text-mono">
-                        <div class="mb-xs"><a href="#authentication">Authentication</a></div>
-                        <div class="mb-xs"><a href="#request-format">Request Format</a></div>
-                        <div class="mb-xs"><a href="#listing-endpoints">Listing Endpoints</a></div>
-                        <div class="mb-xs"><a href="#error-handling">Error Handling</a></div>
-                        <div class="mb-xs"><a href="#rate-limits">Rate Limits</a></div>
-                        <div class="mb-xs"><a href="#content-security">Content Security</a></div>
+                    <div class="sticky-sidebar-header py-xl">
+                        <select refs="api-nav@select" name="navigation" id="navigation">
+                            <option value="getting-started" selected>Jump To Section</option>
+                            <option value="getting-started">Getting Started</option>
+                            @foreach($docs as $model => $endpoints)
+                                <option value="{{ str_replace(' ', '-', $model) }}">{{ ucfirst($model) }}</option>
+                                @if($model === 'docs' || $model === 'shelves')
+                                    <hr>
+                                @endif
+                            @endforeach
+                        </select>
+                    </div>
+
+                    <div class="mb-xl">
+                        <p id="sidebar-header-getting-started" class="text-uppercase text-muted mb-xm"><strong>Getting Started</strong></p>
+                        <div class="text-mono">
+                            <div class="mb-xs"><a href="#authentication">Authentication</a></div>
+                            <div class="mb-xs"><a href="#request-format">Request Format</a></div>
+                            <div class="mb-xs"><a href="#listing-endpoints">Listing Endpoints</a></div>
+                            <div class="mb-xs"><a href="#error-handling">Error Handling</a></div>
+                            <div class="mb-xs"><a href="#rate-limits">Rate Limits</a></div>
+                            <div class="mb-xs"><a href="#content-security">Content Security</a></div>
+                        </div>
                     </div>
 
                     @foreach($docs as $model => $endpoints)
-                        <p class="text-uppercase text-muted mb-xm mt-l"><strong>{{ $model }}</strong></p>
+                        <div class="mb-xl">
+                            <p id="sidebar-header-{{ str_replace(' ', '-', $model) }}" class="text-uppercase text-muted mb-xm"><strong>{{ $model }}</strong></p>
 
-                        @foreach($endpoints as $endpoint)
-                            <div class="mb-xs">
-                                <a href="#{{ $endpoint['name'] }}" class="text-mono inline block mr-s">
-                                    <span class="api-method" data-method="{{ $endpoint['method'] }}">{{ $endpoint['method'] }}</span>
-                                </a>
-                                <a href="#{{ $endpoint['name'] }}" class="text-mono">
-                                    {{ $endpoint['controller_method_kebab'] }}
-                                </a>
-                            </div>
-                        @endforeach
+                            @foreach($endpoints as $endpoint)
+                                <div class="mb-xs">
+                                    <a href="#{{ $endpoint['name'] }}" class="text-mono inline block mr-s">
+                                        <span class="api-method" data-method="{{ $endpoint['method'] }}">{{ $endpoint['method'] }}</span>
+                                    </a>
+                                    <a href="#{{ $endpoint['name'] }}" class="text-mono">
+                                        {{ $endpoint['controller_method_kebab'] }}
+                                    </a>
+                                </div>
+                            @endforeach
+                        </div>
                     @endforeach
                 </div>
             </div>
 
-            <div style="overflow: auto;">
+            <div class="pt-xl" style="overflow: auto;">
 
-                <section component="code-highlighter" class="card content-wrap auto-height">
+                <section id="section-getting-started" component="code-highlighter" class="card content-wrap auto-height">
                     @include('api-docs.parts.getting-started')
                 </section>
 
                 @foreach($docs as $model => $endpoints)
-                    <section class="card content-wrap auto-height">
+                    <section id="section-{{ str_replace(' ', '-', $model) }}" class="card content-wrap auto-height">
                         <h1 class="list-heading text-capitals">{{ $model }}</h1>
-
+                        @if($endpoints[0]['model_description'])
+                            <p>{{ $endpoints[0]['model_description'] }}</p>
+                        @endif
                         @foreach($endpoints as $endpoint)
                             @include('api-docs.parts.endpoint', ['endpoint' => $endpoint, 'loop' => $loop])
                         @endforeach
diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php
index 229fe7dce..663389047 100644
--- a/resources/views/api-docs/parts/getting-started.blade.php
+++ b/resources/views/api-docs/parts/getting-started.blade.php
@@ -2,7 +2,7 @@
 
 <p class="mb-none">
     This documentation covers use of the REST API. <br>
-    Examples of API usage, in a variety of programming languages, can be found in the <a href="https://github.com/BookStackApp/api-scripts" target="_blank" rel="noopener noreferrer">BookStack api-scripts repo on GitHub</a>.
+    Examples of API usage, in a variety of programming languages, can be found in the <a href="https://codeberg.org/bookstack/api-scripts" target="_blank" rel="noopener noreferrer">BookStack api-scripts repo on GitHub</a>.
 
     <br> <br>
     Some alternative options for extension and customization can be found below:
diff --git a/resources/views/attachments/manager-list.blade.php b/resources/views/attachments/manager-list.blade.php
index 6314aa7b5..10ede4aae 100644
--- a/resources/views/attachments/manager-list.blade.php
+++ b/resources/views/attachments/manager-list.blade.php
@@ -16,7 +16,7 @@
                         type="button"
                         title="{{ trans('entities.attachments_insert_link') }}"
                         class="drag-card-action text-center text-link">@icon('link')</button>
-                @if(userCan('attachment-update', $attachment))
+                @if(userCan(\BookStack\Permissions\Permission::AttachmentUpdate, $attachment))
                     <button component="event-emit-select"
                             option:event-emit-select:name="edit"
                             option:event-emit-select:id="{{ $attachment->id }}"
@@ -24,7 +24,7 @@
                             title="{{ trans('common.edit') }}"
                             class="drag-card-action text-center text-link">@icon('edit')</button>
                 @endif
-                @if(userCan('attachment-delete', $attachment))
+                @if(userCan(\BookStack\Permissions\Permission::AttachmentDelete, $attachment))
                     <div component="dropdown" class="flex-fill relative">
                         <button refs="dropdown@toggle"
                                 type="button"
diff --git a/resources/views/books/edit.blade.php b/resources/views/books/edit.blade.php
index 6efd1caea..7f2c8c1b7 100644
--- a/resources/views/books/edit.blade.php
+++ b/resources/views/books/edit.blade.php
@@ -26,7 +26,7 @@
         </main>
 
 
-        @if(userCan('book-delete', $book) && userCan('book-create-all') && userCan('bookshelf-create-all'))
+        @if(userCan(\BookStack\Permissions\Permission::BookDelete, $book) && userCan(\BookStack\Permissions\Permission::BookCreateAll) && userCan(\BookStack\Permissions\Permission::BookshelfCreateAll))
             @include('books.parts.convert-to-shelf', ['book' => $book])
         @endif
     </div>
diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php
index 197de011d..52d23241a 100644
--- a/resources/views/books/index.blade.php
+++ b/resources/views/books/index.blade.php
@@ -36,7 +36,7 @@
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-link">
-            @if(userCan('book-create-all'))
+            @if(userCan(\BookStack\Permissions\Permission::BookCreateAll))
                 <a href="{{ url("/create-book") }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.books_create') }}</span>
@@ -50,7 +50,7 @@
                 <span>{{ trans('entities.tags_view_tags') }}</span>
             </a>
 
-            @if(userCan('content-import'))
+            @if(userCan(\BookStack\Permissions\Permission::ContentImport))
                 <a href="{{ url('/import') }}" class="icon-list-item">
                     <span>@icon('upload')</span>
                     <span>{{ trans('entities.import') }}</span>
diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php
index ee261e72d..bb2cc936f 100644
--- a/resources/views/books/parts/form.blade.php
+++ b/resources/views/books/parts/form.blade.php
@@ -1,7 +1,3 @@
-@push('head')
-    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
-@endpush
-
 {{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
@@ -22,7 +18,7 @@
 
         @include('form.image-picker', [
             'defaultImage' => url('/book_default_cover.png'),
-            'currentImage' => (isset($model) && $model->cover) ? $model->getBookCover() : url('/book_default_cover.png') ,
+            'currentImage' => (($model ?? null)?->coverInfo()->getUrl(440, 250, null) ?? url('/book_default_cover.png')),
             'name' => 'image',
             'imageClass' => 'cover'
         ])
diff --git a/resources/views/books/parts/list-item.blade.php b/resources/views/books/parts/list-item.blade.php
index a3ff0971f..0852670fe 100644
--- a/resources/views/books/parts/list-item.blade.php
+++ b/resources/views/books/parts/list-item.blade.php
@@ -1,11 +1,16 @@
+@php
+    /**
+     * @var \BookStack\Entities\Models\Book $book
+     */
+@endphp
 <a href="{{ $book->getUrl() }}" class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}">
-    <div class="entity-list-item-image bg-book" style="background-image: url('{{ $book->getBookCover() }}')">
+    <div class="entity-list-item-image bg-book" style="background-image: url('{{ $book->coverInfo()->getUrl() }}')">
         @icon('book')
     </div>
     <div class="content">
         <h4 class="entity-list-item-name break-text">{{ $book->name }}</h4>
         <div class="entity-item-snippet">
-            <p class="text-muted break-text mb-s text-limit-lines-1">{{ $book->description }}</p>
+            <p class="text-muted break-text mb-s text-limit-lines-1">{{ $book->descriptionInfo()->getPlain() }}</p>
         </div>
     </div>
 </a>
\ No newline at end of file
diff --git a/resources/views/books/parts/list.blade.php b/resources/views/books/parts/list.blade.php
index 8b658c711..13784d24e 100644
--- a/resources/views/books/parts/list.blade.php
+++ b/resources/views/books/parts/list.blade.php
@@ -24,7 +24,7 @@
         </div>
     @else
         <p class="text-muted">{{ trans('entities.books_empty') }}</p>
-        @if(userCan('book-create-all'))
+        @if(userCan(\BookStack\Permissions\Permission::BookCreateAll))
             <div class="icon-list block inline">
                 <a href="{{ url("/create-book") }}"
                    class="icon-list-item text-book">
diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php
index dbb09fc9e..d510c8fd5 100644
--- a/resources/views/books/show.blade.php
+++ b/resources/views/books/show.blade.php
@@ -8,8 +8,8 @@
 
 @push('social-meta')
     <meta property="og:description" content="{{ Str::limit($book->description, 100, '...') }}">
-    @if($book->cover)
-        <meta property="og:image" content="{{ $book->getBookCover() }}">
+    @if($book->coverInfo()->exists())
+        <meta property="og:image" content="{{ $book->coverInfo()->getUrl() }}">
     @endif
 @endpush
 
@@ -26,7 +26,7 @@
     <main class="content-wrap card">
         <h1 class="break-text">{{$book->name}}</h1>
         <div refs="entity-search@contentView" class="book-content">
-            <div class="text-muted break-text">{!! $book->descriptionHtml() !!}</div>
+            <div class="text-muted break-text">{!! $book->descriptionInfo()->getHtml() !!}</div>
             @if(count($bookChildren) > 0)
                 <div class="entity-list book-contents">
                     @foreach($bookChildren as $childElement)
@@ -43,13 +43,13 @@
                     <p class="text-muted italic mb-m mt-xl">{{ trans('entities.books_empty_contents') }}</p>
 
                     <div class="icon-list block inline">
-                        @if(userCan('page-create', $book))
+                        @if(userCan(\BookStack\Permissions\Permission::PageCreate, $book))
                             <a href="{{ $book->getUrl('/create-page') }}" class="icon-list-item text-page">
                                 <span class="icon">@icon('page')</span>
                                 <span>{{ trans('entities.books_empty_create_page') }}</span>
                             </a>
                         @endif
-                        @if(userCan('chapter-create', $book))
+                        @if(userCan(\BookStack\Permissions\Permission::ChapterCreate, $book))
                             <a href="{{ $book->getUrl('/create-chapter') }}" class="icon-list-item text-chapter">
                                 <span class="icon">@icon('chapter')</span>
                                 <span>{{ trans('entities.books_empty_add_chapter') }}</span>
@@ -73,7 +73,7 @@
             @include('entities.meta', ['entity' => $book, 'watchOptions' => $watchOptions])
             @if($book->hasPermissions())
                 <div class="active-restriction">
-                    @if(userCan('restrictions-manage', $book))
+                    @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $book))
                         <a href="{{ $book->getUrl('/permissions') }}" class="entity-meta-item">
                             @icon('lock')
                             <div>{{ trans('entities.books_permissions_active') }}</div>
@@ -93,13 +93,13 @@
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-link">
 
-            @if(userCan('page-create', $book))
+            @if(userCan(\BookStack\Permissions\Permission::PageCreate, $book))
                 <a href="{{ $book->getUrl('/create-page') }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.pages_new') }}</span>
                 </a>
             @endif
-            @if(userCan('chapter-create', $book))
+            @if(userCan(\BookStack\Permissions\Permission::ChapterCreate, $book))
                 <a href="{{ $book->getUrl('/create-chapter') }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.chapters_new') }}</span>
@@ -108,7 +108,7 @@
 
             <hr class="primary-background">
 
-            @if(userCan('book-update', $book))
+            @if(userCan(\BookStack\Permissions\Permission::BookUpdate, $book))
                 <a href="{{ $book->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
@@ -118,19 +118,19 @@
                     <span>{{ trans('common.sort') }}</span>
                 </a>
             @endif
-            @if(userCan('book-create-all'))
+            @if(userCan(\BookStack\Permissions\Permission::BookCreateAll))
                 <a href="{{ $book->getUrl('/copy') }}" data-shortcut="copy" class="icon-list-item">
                     <span>@icon('copy')</span>
                     <span>{{ trans('common.copy') }}</span>
                 </a>
             @endif
-            @if(userCan('restrictions-manage', $book))
+            @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $book))
                 <a href="{{ $book->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
                     <span>@icon('lock')</span>
                     <span>{{ trans('entities.permissions') }}</span>
                 </a>
             @endif
-            @if(userCan('book-delete', $book))
+            @if(userCan(\BookStack\Permissions\Permission::BookDelete, $book))
                 <a href="{{ $book->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
                     <span>@icon('delete')</span>
                     <span>{{ trans('common.delete') }}</span>
@@ -145,7 +145,7 @@
             @if(!user()->isGuest())
                 @include('entities.favourite-action', ['entity' => $book])
             @endif
-            @if(userCan('content-export'))
+            @if(userCan(\BookStack\Permissions\Permission::ContentExport))
                 @include('entities.export-menu', ['entity' => $book])
             @endif
         </div>
diff --git a/resources/views/chapters/edit.blade.php b/resources/views/chapters/edit.blade.php
index 36058eff8..5ace966f5 100644
--- a/resources/views/chapters/edit.blade.php
+++ b/resources/views/chapters/edit.blade.php
@@ -23,7 +23,7 @@
             </form>
         </main>
 
-        @if(userCan('chapter-delete', $chapter) && userCan('book-create-all'))
+        @if(userCan(\BookStack\Permissions\Permission::ChapterDelete, $chapter) && userCan(\BookStack\Permissions\Permission::BookCreateAll))
             @include('chapters.parts.convert-to-book')
         @endif
 
diff --git a/resources/views/chapters/parts/form.blade.php b/resources/views/chapters/parts/form.blade.php
index 602693916..70721631d 100644
--- a/resources/views/chapters/parts/form.blade.php
+++ b/resources/views/chapters/parts/form.blade.php
@@ -1,7 +1,3 @@
-@push('head')
-    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
-@endpush
-
 {{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php
index 45e43ad96..585bf8a3b 100644
--- a/resources/views/chapters/show.blade.php
+++ b/resources/views/chapters/show.blade.php
@@ -24,7 +24,7 @@
     <main class="content-wrap card">
         <h1 class="break-text">{{ $chapter->name }}</h1>
         <div refs="entity-search@contentView" class="chapter-content">
-            <div class="text-muted break-text">{!! $chapter->descriptionHtml() !!}</div>
+            <div class="text-muted break-text">{!! $chapter->descriptionInfo()->getHtml() !!}</div>
             @if(count($pages) > 0)
                 <div class="entity-list book-contents">
                     @foreach($pages as $page)
@@ -37,13 +37,13 @@
                     <p class="text-muted italic mb-m mt-xl">{{ trans('entities.chapters_empty') }}</p>
 
                     <div class="icon-list block inline">
-                        @if(userCan('page-create', $chapter))
+                        @if(userCan(\BookStack\Permissions\Permission::PageCreate, $chapter))
                             <a href="{{ $chapter->getUrl('/create-page') }}" class="icon-list-item text-page">
                                 <span class="icon">@icon('page')</span>
                                 <span>{{ trans('entities.books_empty_create_page') }}</span>
                             </a>
                         @endif
-                        @if(userCan('book-update', $book))
+                        @if(userCan(\BookStack\Permissions\Permission::BookUpdate, $book))
                             <a href="{{ $book->getUrl('/sort') }}" class="icon-list-item text-book">
                                 <span class="icon">@icon('book')</span>
                                 <span>{{ trans('entities.books_empty_sort_current_book') }}</span>
@@ -71,7 +71,7 @@
 
             @if($book->hasPermissions())
                 <div class="active-restriction">
-                    @if(userCan('restrictions-manage', $book))
+                    @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $book))
                         <a href="{{ $book->getUrl('/permissions') }}" class="entity-meta-item">
                             @icon('lock')
                             <div>{{ trans('entities.books_permissions_active') }}</div>
@@ -87,7 +87,7 @@
 
             @if($chapter->hasPermissions())
                 <div class="active-restriction">
-                    @if(userCan('restrictions-manage', $chapter))
+                    @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $chapter))
                         <a href="{{ $chapter->getUrl('/permissions') }}" class="entity-meta-item">
                             @icon('lock')
                             <div>{{ trans('entities.chapters_permissions_active') }}</div>
@@ -107,7 +107,7 @@
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-link">
 
-            @if(userCan('page-create', $chapter))
+            @if(userCan(\BookStack\Permissions\Permission::PageCreate, $chapter))
                 <a href="{{ $chapter->getUrl('/create-page') }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.pages_new') }}</span>
@@ -116,38 +116,38 @@
 
             <hr class="primary-background"/>
 
-            @if(userCan('chapter-update', $chapter))
+            @if(userCan(\BookStack\Permissions\Permission::ChapterUpdate, $chapter))
                 <a href="{{ $chapter->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
                 </a>
             @endif
-            @if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCan('chapter-create-all') || userCan('chapter-create-own'))
+            @if(userCanOnAny(\BookStack\Permissions\Permission::Create, \BookStack\Entities\Models\Book::class) || userCan(\BookStack\Permissions\Permission::ChapterCreateAll) || userCan(\BookStack\Permissions\Permission::ChapterCreateOwn))
                 <a href="{{ $chapter->getUrl('/copy') }}" data-shortcut="copy" class="icon-list-item">
                     <span>@icon('copy')</span>
                     <span>{{ trans('common.copy') }}</span>
                 </a>
             @endif
-            @if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter))
+            @if(userCan(\BookStack\Permissions\Permission::ChapterUpdate, $chapter) && userCan(\BookStack\Permissions\Permission::ChapterDelete, $chapter))
                 <a href="{{ $chapter->getUrl('/move') }}" data-shortcut="move" class="icon-list-item">
                     <span>@icon('folder')</span>
                     <span>{{ trans('common.move') }}</span>
                 </a>
             @endif
-            @if(userCan('restrictions-manage', $chapter))
+            @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $chapter))
                 <a href="{{ $chapter->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
                     <span>@icon('lock')</span>
                     <span>{{ trans('entities.permissions') }}</span>
                 </a>
             @endif
-            @if(userCan('chapter-delete', $chapter))
+            @if(userCan(\BookStack\Permissions\Permission::ChapterDelete, $chapter))
                 <a href="{{ $chapter->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
                     <span>@icon('delete')</span>
                     <span>{{ trans('common.delete') }}</span>
                 </a>
             @endif
 
-            @if($chapter->book && userCan('book-update', $chapter->book))
+            @if($chapter->book && userCan(\BookStack\Permissions\Permission::BookUpdate, $chapter->book))
                 <hr class="primary-background"/>
                 <a href="{{ $chapter->book->getUrl('/sort') }}" data-shortcut="sort" class="icon-list-item">
                     <span>@icon('sort')</span>
@@ -163,7 +163,7 @@
             @if(!user()->isGuest())
                 @include('entities.favourite-action', ['entity' => $chapter])
             @endif
-            @if(userCan('content-export'))
+            @if(userCan(\BookStack\Permissions\Permission::ContentExport))
                 @include('entities.export-menu', ['entity' => $chapter])
             @endif
         </div>
diff --git a/resources/views/comments/comment-branch.blade.php b/resources/views/comments/comment-branch.blade.php
index 78d19ac3e..658c33219 100644
--- a/resources/views/comments/comment-branch.blade.php
+++ b/resources/views/comments/comment-branch.blade.php
@@ -1,13 +1,16 @@
+{{--
+$branch CommentTreeNode
+--}}
 <div class="comment-branch">
-    <div class="mb-m">
-        @include('comments.comment', ['comment' => $branch['comment']])
+    <div>
+        @include('comments.comment', ['comment' => $branch->comment])
     </div>
     <div class="flex-container-row">
         <div class="comment-thread-indicator-parent">
             <div class="comment-thread-indicator"></div>
         </div>
         <div class="comment-branch-children flex">
-            @foreach($branch['children'] as $childBranch)
+            @foreach($branch->children as $childBranch)
                 @include('comments.comment-branch', ['branch' => $childBranch])
             @endforeach
         </div>
diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php
index 2bf89d683..10cd02035 100644
--- a/resources/views/comments/comment.blade.php
+++ b/resources/views/comments/comment.blade.php
@@ -4,10 +4,9 @@
 <div component="{{ $readOnly ? '' : 'page-comment' }}"
      option:page-comment:comment-id="{{ $comment->id }}"
      option:page-comment:comment-local-id="{{ $comment->local_id }}"
-     option:page-comment:comment-parent-id="{{ $comment->parent_id }}"
      option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
      option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}"
-     option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}"
+     option:page-comment:archive-text="{{ $comment->archived ? trans('entities.comment_unarchive_success') : trans('entities.comment_archive_success') }}"
      option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
      id="comment{{$comment->local_id}}"
      class="comment-box">
@@ -15,7 +14,8 @@
         <div class="flex-container-row wrap items-center gap-x-xs">
             @if ($comment->createdBy)
                 <div>
-                    <img width="50" src="{{ $comment->createdBy->getAvatar(50) }}" class="avatar block mr-xs" alt="{{ $comment->createdBy->name }}">
+                    <img width="50" src="{{ $comment->createdBy->getAvatar(50) }}" class="avatar block mr-xs"
+                         alt="{{ $comment->createdBy->name }}">
                 </div>
             @endif
             <div class="meta text-muted flex-container-row wrap items-center flex text-small">
@@ -24,44 +24,55 @@
                 @else
                     {{ trans('common.deleted_user') }}
                 @endif
-                <span title="{{ $comment->created_at }}">&nbsp;{{ trans('entities.comment_created', ['createDiff' => $comment->created_at->diffForHumans() ]) }}</span>
+                <span title="{{ $dates->absolute($comment->created_at) }}">&nbsp;{{ trans('entities.comment_created', ['createDiff' => $dates->relative($comment->created_at) ]) }}</span>
                 @if($comment->isUpdated())
                     <span class="mx-xs">&bull;</span>
-                    <span title="{{ trans('entities.comment_updated', ['updateDiff' => $comment->updated_at, 'username' => $comment->updatedBy->name ?? trans('common.deleted_user')]) }}">
+                    <span title="{{ trans('entities.comment_updated', ['updateDiff' => $dates->absolute($comment->updated_at), 'username' => $comment->updatedBy->name ?? trans('common.deleted_user')]) }}">
                  {{ trans('entities.comment_updated_indicator') }}
                     </span>
                 @endif
             </div>
             <div class="right-meta flex-container-row justify-flex-end items-center px-s">
-                @if(!$readOnly && (userCan('comment-create-all') || userCan('comment-update', $comment) || userCan('comment-delete', $comment)))
-                <div class="actions mr-s">
-                    @if(userCan('comment-create-all'))
-                        <button refs="page-comment@reply-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('reply') {{ trans('common.reply') }}</button>
-                    @endif
-                    @if(userCan('comment-update', $comment))
-                        <button refs="page-comment@edit-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('edit') {{ trans('common.edit') }}</button>
-                    @endif
-                    @if(userCan('comment-delete', $comment))
-                        <div component="dropdown" class="dropdown-container">
-                            <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" class="text-button text-muted hover-underline text-small p-xs">@icon('delete') {{ trans('common.delete') }}</button>
-                            <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
-                                <li class="px-m text-small text-muted pb-s">{{trans('entities.comment_delete_confirm')}}</li>
-                                <li>
-                                    <button refs="page-comment@delete-button" type="button" class="text-button text-neg icon-item">
-                                        @icon('delete')
-                                        <div>{{ trans('common.delete') }}</div>
-                                    </button>
-                                </li>
-                            </ul>
-                        </div>
-                    @endif
-                    <span class="text-muted">
+                @if(!$readOnly && (userCan(\BookStack\Permissions\Permission::CommentCreateAll) || userCan(\BookStack\Permissions\Permission::CommentUpdate, $comment) || userCan(\BookStack\Permissions\Permission::CommentDelete, $comment)))
+                    <div class="actions mr-s">
+                        @if(userCan(\BookStack\Permissions\Permission::CommentCreateAll))
+                            <button refs="page-comment@reply-button" type="button"
+                                    class="text-button text-muted hover-underline text-small p-xs">@icon('reply') {{ trans('common.reply') }}</button>
+                        @endif
+                        @if(!$comment->parent_id && (userCan(\BookStack\Permissions\Permission::CommentUpdate, $comment) || userCan(\BookStack\Permissions\Permission::CommentDelete, $comment)))
+                            <button refs="page-comment@archive-button"
+                                    type="button"
+                                    data-is-archived="{{ $comment->archived ? 'true' : 'false' }}"
+                                    class="text-button text-muted hover-underline text-small p-xs">@icon('archive') {{ trans('common.' . ($comment->archived ? 'unarchive' : 'archive')) }}</button>
+                        @endif
+                        @if(userCan(\BookStack\Permissions\Permission::CommentUpdate, $comment))
+                            <button refs="page-comment@edit-button" type="button"
+                                    class="text-button text-muted hover-underline text-small p-xs">@icon('edit') {{ trans('common.edit') }}</button>
+                        @endif
+                        @if(userCan(\BookStack\Permissions\Permission::CommentDelete, $comment))
+                            <div component="dropdown" class="dropdown-container">
+                                <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false"
+                                        class="text-button text-muted hover-underline text-small p-xs">@icon('delete') {{ trans('common.delete') }}</button>
+                                <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
+                                    <li class="px-m text-small text-muted pb-s">{{trans('entities.comment_delete_confirm')}}</li>
+                                    <li>
+                                        <button refs="page-comment@delete-button" type="button"
+                                                class="text-button text-neg icon-item">
+                                            @icon('delete')
+                                            <div>{{ trans('common.delete') }}</div>
+                                        </button>
+                                    </li>
+                                </ul>
+                            </div>
+                        @endif
+                        <span class="text-muted">
                         &nbsp;&bull;&nbsp;
                     </span>
-                </div>
+                    </div>
                 @endif
                 <div>
-                    <a class="bold text-muted text-small" href="#comment{{$comment->local_id}}">#{{$comment->local_id}}</a>
+                    <a class="bold text-muted text-small"
+                       href="#comment{{$comment->local_id}}">#{{$comment->local_id}}</a>
                 </div>
             </div>
         </div>
@@ -71,19 +82,33 @@
     <div refs="page-comment@content-container" class="content">
         @if ($comment->parent_id)
             <p class="comment-reply">
-                <a class="text-muted text-small" href="#comment{{ $comment->parent_id }}">@icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}</a>
+                <a class="text-muted text-small"
+                   href="#comment{{ $comment->parent_id }}">@icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}</a>
             </p>
         @endif
+        @if($comment->content_ref)
+            <div class="comment-reference-indicator-wrap">
+                <a component="page-comment-reference"
+                   option:page-comment-reference:reference="{{ $comment->content_ref }}"
+                   option:page-comment-reference:view-comment-text="{{ trans('entities.comment_view') }}"
+                   option:page-comment-reference:jump-to-thread-text="{{ trans('entities.comment_jump_to_thread') }}"
+                   option:page-comment-reference:close-text="{{ trans('common.close') }}"
+                   href="#">@icon('bookmark'){{ trans('entities.comment_reference') }}
+                    <span>{{ trans('entities.comment_reference_outdated') }}</span></a>
+            </div>
+        @endif
         {!! $commentHtml  !!}
     </div>
 
-    @if(!$readOnly && userCan('comment-update', $comment))
+    @if(!$readOnly && userCan(\BookStack\Permissions\Permission::CommentUpdate, $comment))
         <form novalidate refs="page-comment@form" hidden class="content pt-s px-s block">
             <div class="form-group description-input">
-                <textarea refs="page-comment@input" name="html" rows="3" placeholder="{{ trans('entities.comment_placeholder') }}">{{ $commentHtml }}</textarea>
+                <textarea refs="page-comment@input" name="html" rows="3"
+                          placeholder="{{ trans('entities.comment_placeholder') }}">{{ $commentHtml }}</textarea>
             </div>
             <div class="form-group text-right">
-                <button type="button" class="button outline" refs="page-comment@form-cancel">{{ trans('common.cancel') }}</button>
+                <button type="button" class="button outline"
+                        refs="page-comment@form-cancel">{{ trans('common.cancel') }}</button>
                 <button type="submit" class="button">{{ trans('entities.comment_save') }}</button>
             </div>
         </form>
diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php
index 48bf885ff..9dc0a9b89 100644
--- a/resources/views/comments/comments.blade.php
+++ b/resources/views/comments/comments.blade.php
@@ -1,43 +1,77 @@
-<section component="page-comments"
+<section components="page-comments tabs"
          option:page-comments:page-id="{{ $page->id }}"
          option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
-         option:page-comments:count-text="{{ trans('entities.comment_count') }}"
-         option:page-comments:wysiwyg-language="{{ $locale->htmlLang() }}"
+         option:page-comments:count-text="{{ trans('entities.comment_thread_count') }}"
+         option:page-comments:archived-count-text="{{ trans('entities.comment_archived_count') }}"
          option:page-comments:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
-         class="comments-list"
+         class="comments-list tab-container"
          aria-label="{{ trans('entities.comments') }}">
 
-    <div refs="page-comments@comment-count-bar" class="grid half left-focus v-center no-row-gap">
-        <h5 refs="page-comments@comments-title">{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}</h5>
-        @if ($commentTree->empty() && userCan('comment-create-all'))
-            <div class="text-m-right" refs="page-comments@add-button-container">
+    <div refs="page-comments@comment-count-bar" class="flex-container-row items-center">
+        <div role="tablist" class="flex">
+            <button type="button"
+                    role="tab"
+                    id="comment-tab-active"
+                    aria-controls="comment-tab-panel-active"
+                    refs="page-comments@active-tab"
+                    aria-selected="true">{{ trans_choice('entities.comment_thread_count', $commentTree->activeThreadCount()) }}</button>
+            <button type="button"
+                    role="tab"
+                    id="comment-tab-archived"
+                    aria-controls="comment-tab-panel-archived"
+                    refs="page-comments@archived-tab"
+                    aria-selected="false">{{ trans_choice('entities.comment_archived_count', count($commentTree->getArchived())) }}</button>
+        </div>
+        @if ($commentTree->empty() && userCan(\BookStack\Permissions\Permission::CommentCreateAll))
+            <div refs="page-comments@add-button-container" class="ml-m flex-container-row" >
                 <button type="button"
                         refs="page-comments@add-comment-button"
-                        class="button outline">{{ trans('entities.comment_add') }}</button>
+                        class="button outline mb-m ml-auto">{{ trans('entities.comment_add') }}</button>
             </div>
         @endif
     </div>
 
-    <div refs="page-comments@commentContainer" class="comment-container">
-        @foreach($commentTree->get() as $branch)
+    <div id="comment-tab-panel-active"
+         refs="page-comments@active-container"
+         tabindex="0"
+         role="tabpanel"
+         aria-labelledby="comment-tab-active"
+         class="comment-container no-outline">
+        <div refs="page-comments@comment-container">
+            @foreach($commentTree->getActive() as $branch)
+                @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
+            @endforeach
+        </div>
+
+        <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p>
+
+        @if(userCan(\BookStack\Permissions\Permission::CommentCreateAll))
+            @include('comments.create')
+            @if (!$commentTree->empty())
+                <div refs="page-comments@addButtonContainer" class="ml-m flex-container-row">
+                    <button type="button"
+                            refs="page-comments@add-comment-button"
+                            class="button outline mb-m ml-auto">{{ trans('entities.comment_add') }}</button>
+                </div>
+            @endif
+        @endif
+    </div>
+
+    <div refs="page-comments@archive-container"
+         id="comment-tab-panel-archived"
+         tabindex="0"
+         role="tabpanel"
+         aria-labelledby="comment-tab-archived"
+         hidden="hidden"
+         class="comment-container no-outline">
+        @foreach($commentTree->getArchived() as $branch)
             @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
         @endforeach
+            <p class="text-center text-muted italic empty-state">{{ trans('entities.comment_none') }}</p>
     </div>
 
-    @if(userCan('comment-create-all'))
-        @include('comments.create')
-        @if (!$commentTree->empty())
-            <div refs="page-comments@addButtonContainer" class="text-right">
-                <button type="button"
-                        refs="page-comments@add-comment-button"
-                        class="button outline">{{ trans('entities.comment_add') }}</button>
-            </div>
-        @endif
-    @endif
-
-    @if(userCan('comment-create-all') || $commentTree->canUpdateAny())
+    @if(userCan(\BookStack\Permissions\Permission::CommentCreateAll) || $commentTree->canUpdateAny())
         @push('body-end')
-            <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script>
             @include('form.editor-translations')
             @include('entities.selector-popup')
         @endpush
diff --git a/resources/views/comments/create.blade.php b/resources/views/comments/create.blade.php
index 417f0c606..134ed5164 100644
--- a/resources/views/comments/create.blade.php
+++ b/resources/views/comments/create.blade.php
@@ -12,6 +12,16 @@
                 </div>
             </div>
         </div>
+        <div refs="page-comments@reference-row" hidden class="primary-background-light text-muted px-s py-xs">
+            <div class="grid left-focus v-center">
+                <div>
+                    <a refs="page-comments@formReferenceLink" href="#">{{ trans('entities.comment_reference') }}</a>
+                </div>
+                <div class="text-right">
+                    <button refs="page-comments@remove-reference-button" class="text-button">{{ trans('common.remove') }}</button>
+                </div>
+            </div>
+        </div>
 
         <div class="content px-s pt-s">
             <form refs="page-comments@form" novalidate>
diff --git a/resources/views/common/activity-item.blade.php b/resources/views/common/activity-item.blade.php
index 1c970084f..1d3c7bd75 100644
--- a/resources/views/common/activity-item.blade.php
+++ b/resources/views/common/activity-item.blade.php
@@ -26,5 +26,5 @@
 
     <br>
 
-    <span class="text-muted"><small>@icon('time'){{ $activity->created_at->diffForHumans() }}</small></span>
+    <span class="text-muted" title="{{ $dates->absolute($activity->created_at) }}"><small>@icon('time'){{ $dates->relative($activity->created_at) }}</small></span>
 </div>
diff --git a/resources/views/common/dark-mode-toggle.blade.php b/resources/views/common/dark-mode-toggle.blade.php
index 531755109..384932519 100644
--- a/resources/views/common/dark-mode-toggle.blade.php
+++ b/resources/views/common/dark-mode-toggle.blade.php
@@ -3,8 +3,8 @@
     {{ method_field('patch') }}
     <input type="hidden" name="_return" value="{{ url()->current() }}">
     @if(setting()->getForCurrentUser('dark-mode-enabled'))
-        <button class="{{ $classes ?? '' }}"><span>@icon('light-mode')</span><span>{{ trans('common.light_mode') }}</span></button>
+        <button class="{{ $classes ?? '' }}" role="{{ $butonRole ?? '' }}"><span>@icon('light-mode')</span><span>{{ trans('common.light_mode') }}</span></button>
     @else
-        <button class="{{ $classes ?? '' }}"><span>@icon('dark-mode')</span><span>{{ trans('common.dark_mode') }}</span></button>
+        <button class="{{ $classes ?? '' }}" role="{{ $butonRole ?? '' }}"><span>@icon('dark-mode')</span><span>{{ trans('common.dark_mode') }}</span></button>
     @endif
 </form>
\ No newline at end of file
diff --git a/resources/views/common/sort.blade.php b/resources/views/common/sort.blade.php
index e5336d3a2..d47351cb3 100644
--- a/resources/views/common/sort.blade.php
+++ b/resources/views/common/sort.blade.php
@@ -29,10 +29,14 @@
 
         <div class="list-sort">
             <div component="dropdown" class="list-sort-type dropdown-container">
-                <div refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" tabindex="0">{{ $options[$selectedSort] }}</div>
-                <ul refs="dropdown@menu list-sort-control@menu" class="dropdown-menu">
+                <button refs="dropdown@toggle"
+                        aria-haspopup="true"
+                        aria-expanded="false"
+                        aria-label="{{ trans('common.sort_options') }}"
+                        class="list-sort-toggle">{{ $options[$selectedSort] }}</button>
+                <ul refs="dropdown@menu list-sort-control@menu" class="dropdown-menu" role="menu">
                     @foreach($options as $key => $label)
-                        <li @if($key === $selectedSort) class="active" @endif><a href="#" data-sort-value="{{$key}}" class="text-item">{{ $label }}</a></li>
+                        <li @if($key === $selectedSort) class="active" @endif><a href="#" data-sort-value="{{$key}}" role="menuitem" class="text-item">{{ $label }}</a></li>
                     @endforeach
                 </ul>
             </div>
diff --git a/resources/views/entities/body-tag-classes.blade.php b/resources/views/entities/body-tag-classes.blade.php
index 08427f1a5..f9ba023c3 100644
--- a/resources/views/entities/body-tag-classes.blade.php
+++ b/resources/views/entities/body-tag-classes.blade.php
@@ -1 +1 @@
-@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity->tags->all()))->generateAsString() . ' '))
\ No newline at end of file
+@push('body-class', e((new \BookStack\Activity\Tools\TagClassGenerator($entity))->generateAsString() . ' '))
\ No newline at end of file
diff --git a/resources/views/entities/book-tree.blade.php b/resources/views/entities/book-tree.blade.php
index ce016143a..c95b1eebc 100644
--- a/resources/views/entities/book-tree.blade.php
+++ b/resources/views/entities/book-tree.blade.php
@@ -5,7 +5,7 @@
     <h5>{{ trans('entities.books_navigation') }}</h5>
 
     <ul class="sidebar-page-list mt-xs menu entity-list">
-        @if (userCan('view', $book))
+        @if (userCan(\BookStack\Permissions\Permission::BookView, $book))
             <li class="list-item-book book">
                 @include('entities.list-item-basic', ['entity' => $book, 'classes' => ($current->matches($book)? 'selected' : '')])
             </li>
diff --git a/resources/views/entities/breadcrumb-listing.blade.php b/resources/views/entities/breadcrumb-listing.blade.php
index 4f751a93f..180e2d211 100644
--- a/resources/views/entities/breadcrumb-listing.blade.php
+++ b/resources/views/entities/breadcrumb-listing.blade.php
@@ -2,11 +2,14 @@
      option:dropdown-search:url="/search/entity/siblings?entity_type={{$entity->getType()}}&entity_id={{ $entity->id }}"
      option:dropdown-search:local-search-selector=".entity-list-item"
      class="dropdown-search">
-    <div class="dropdown-search-toggle-breadcrumb" refs="dropdown@toggle"
-         aria-haspopup="true" aria-expanded="false" tabindex="0">
-        <div class="separator">@icon('chevron-right')</div>
-    </div>
-    <div refs="dropdown@menu" class="dropdown-search-dropdown card" role="menu">
+    <button class="dropdown-search-toggle-breadcrumb"
+            refs="dropdown@toggle"
+            aria-haspopup="true"
+            aria-expanded="false"
+            title="{{ trans('entities.breadcrumb_siblings_for_' . $entity->getType()) }}">
+        <div role="presentation" class="separator">@icon('chevron-right')</div>
+    </button>
+    <div refs="dropdown@menu" class="dropdown-search-dropdown card">
         <div class="dropdown-search-search">
             @icon('search')
             <input refs="dropdown-search@searchInput"
@@ -18,6 +21,6 @@
         <div refs="dropdown-search@loading">
             @include('common.loading-icon')
         </div>
-        <div refs="dropdown-search@listContainer" class="dropdown-search-list px-m" tabindex="-1"></div>
+        <div refs="dropdown-search@listContainer" class="dropdown-search-list px-m" tabindex="-1" role="list"></div>
     </div>
 </div>
\ No newline at end of file
diff --git a/resources/views/entities/breadcrumbs.blade.php b/resources/views/entities/breadcrumbs.blade.php
index d078d9873..825909947 100644
--- a/resources/views/entities/breadcrumbs.blade.php
+++ b/resources/views/entities/breadcrumbs.blade.php
@@ -38,7 +38,7 @@
                 <span>@icon($crumb['icon'])</span>
                 <span>{{ $crumb['text'] }}</span>
             </a>
-        @elseif($isEntity && userCan('view', $crumb))
+        @elseif($isEntity && userCan(\BookStack\Permissions\Permission::View, $crumb))
             @if($breadcrumbCount > 0)
                 @include('entities.breadcrumb-listing', ['entity' => $crumb])
             @endif
diff --git a/resources/views/entities/export-menu.blade.php b/resources/views/entities/export-menu.blade.php
index e58c842ba..7b5f889ca 100644
--- a/resources/views/entities/export-menu.blade.php
+++ b/resources/views/entities/export-menu.blade.php
@@ -2,23 +2,22 @@
      class="dropdown-container"
      id="export-menu">
 
-    <div refs="dropdown@toggle"
-         class="icon-list-item"
+    <button refs="dropdown@toggle"
+         class="icon-list-item text-link"
          aria-haspopup="true"
          aria-expanded="false"
          aria-label="{{ trans('entities.export') }}"
-         data-shortcut="export"
-         tabindex="0">
+         data-shortcut="export">
         <span>@icon('export')</span>
         <span>{{ trans('entities.export') }}</span>
-    </div>
+    </button>
 
     <ul refs="dropdown@menu" class="wide dropdown-menu" role="menu">
-        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_html') }}</span><span>.html</span></a></li>
-        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
-        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
-        <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
-        <li><a href="{{ $entity->getUrl('/export/zip') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_zip') }}</span><span>.zip</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" role="menuitem" class="label-item"><span>{{ trans('entities.export_html') }}</span><span>.html</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" role="menuitem" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" role="menuitem" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" role="menuitem" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/zip') }}" target="_blank" role="menuitem" class="label-item"><span>{{ trans('entities.export_zip') }}</span><span>.zip</span></a></li>
     </ul>
 
 </div>
diff --git a/resources/views/entities/grid-item.blade.php b/resources/views/entities/grid-item.blade.php
index ee31b53f2..e64d0f42c 100644
--- a/resources/views/entities/grid-item.blade.php
+++ b/resources/views/entities/grid-item.blade.php
@@ -1,7 +1,7 @@
 <a href="{{ $entity->getUrl() }}" class="grid-card"
    data-entity-type="{{ $entity->getType() }}" data-entity-id="{{ $entity->id }}">
     <div class="bg-{{ $entity->getType() }} featured-image-container-wrap">
-        <div class="featured-image-container" @if($entity->cover) style="background-image: url('{{ $entity->getBookCover() }}')"@endif>
+        <div class="featured-image-container" @if($entity->coverInfo()->exists()) style="background-image: url('{{ $entity->coverInfo()->getUrl() }}')"@endif>
         </div>
         @icon($entity->getType())
     </div>
@@ -10,7 +10,7 @@
         <p class="text-muted">{{ $entity->getExcerpt(130) }}</p>
     </div>
     <div class="grid-card-footer text-muted ">
-        <p>@icon('star')<span title="{{ $entity->created_at->toDayDateTimeString() }}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span></p>
-        <p>@icon('edit')<span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span></p>
+        <p>@icon('star')<span title="{{ $dates->absolute($entity->created_at) }}">{{ trans('entities.meta_created', ['timeLength' => $dates->relative($entity->created_at)]) }}</span></p>
+        <p>@icon('edit')<span title="{{ $dates->absolute($entity->updated_at) }}">{{ trans('entities.meta_updated', ['timeLength' => $dates->relative($entity->updated_at)]) }}</span></p>
     </div>
 </a>
\ No newline at end of file
diff --git a/resources/views/entities/list-item-basic.blade.php b/resources/views/entities/list-item-basic.blade.php
index 398c33b93..4c66eec3d 100644
--- a/resources/views/entities/list-item-basic.blade.php
+++ b/resources/views/entities/list-item-basic.blade.php
@@ -1,5 +1,8 @@
 <?php $type = $entity->getType(); ?>
-<a href="{{ $entity->getUrl() }}" class="{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}} {{$classes ?? ''}} entity-list-item" data-entity-type="{{$type}}" data-entity-id="{{$entity->id}}">
+<a href="{{ $entity->getUrl() }}"
+   class="{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}} {{$classes ?? ''}} entity-list-item"
+   data-entity-type="{{$type}}"
+   data-entity-id="{{$entity->id}}">
     <span role="presentation" class="icon text-{{$type}}">@icon($type)</span>
     <div class="content">
             <h4 class="entity-list-item-name break-text">{{ $entity->preview_name ?? $entity->name }}</h4>
diff --git a/resources/views/entities/list-item.blade.php b/resources/views/entities/list-item.blade.php
index 2fadef191..5174de431 100644
--- a/resources/views/entities/list-item.blade.php
+++ b/resources/views/entities/list-item.blade.php
@@ -27,9 +27,9 @@
 @endif
 
 @if(($showUpdatedBy ?? false) && $entity->relationLoaded('updatedBy') && $entity->updatedBy)
-    <small title="{{ $entity->updated_at->toDayDateTimeString() }}">
+    <small title="{{ $dates->absolute($entity->updated_at) }}">
         {!! trans('entities.meta_updated_name', [
-            'timeLength' => $entity->updated_at->diffForHumans(),
+            'timeLength' => $dates->relative($entity->updated_at),
             'user' => e($entity->updatedBy->name)
         ]) !!}
     </small>
diff --git a/resources/views/entities/meta.blade.php b/resources/views/entities/meta.blade.php
index 9d3c4b956..060c197a4 100644
--- a/resources/views/entities/meta.blade.php
+++ b/resources/views/entities/meta.blade.php
@@ -31,7 +31,7 @@
             @icon('star')
             <div>
                 {!! trans('entities.meta_created_name', [
-                    'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>',
+                    'timeLength' => '<span title="'. $dates->absolute($entity->created_at) .'">'. $dates->relative($entity->created_at) . '</span>',
                     'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".e($entity->createdBy->name). "</a>"
                 ]) !!}
             </div>
@@ -39,7 +39,7 @@
     @else
         <div class="entity-meta-item">
             @icon('star')
-            <span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span>
+            <span title="{{ $dates->absolute($entity->created_at) }}">{{ trans('entities.meta_created', ['timeLength' => $dates->relative($entity->created_at)]) }}</span>
         </div>
     @endif
 
@@ -48,7 +48,7 @@
             @icon('edit')
             <div>
                 {!! trans('entities.meta_updated_name', [
-                    'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>',
+                    'timeLength' => '<span title="' . $dates->absolute($entity->updated_at) .'">' . $dates->relative($entity->updated_at) .'</span>',
                     'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".e($entity->updatedBy->name). "</a>"
                 ]) !!}
             </div>
@@ -56,7 +56,7 @@
     @elseif (!$entity->isA('revision'))
         <div class="entity-meta-item">
             @icon('edit')
-            <span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span>
+            <span title="{{ $dates->absolute($entity->updated_at) }}">{{ trans('entities.meta_updated', ['timeLength' => $dates->relative($entity->updated_at)]) }}</span>
         </div>
     @endif
 
diff --git a/resources/views/entities/watch-controls.blade.php b/resources/views/entities/watch-controls.blade.php
index 4a119c272..81e80ed98 100644
--- a/resources/views/entities/watch-controls.blade.php
+++ b/resources/views/entities/watch-controls.blade.php
@@ -1,6 +1,11 @@
 <div component="dropdown"
      class="dropdown-container block my-xxs">
-    <a refs="dropdown@toggle" href="#" class="entity-meta-item my-none">
+    <a refs="dropdown@toggle"
+       aria-haspopup="menu"
+       aria-expanded="false"
+       role="button"
+       href="#"
+       class="entity-meta-item my-none">
         @icon(($ignoring ? 'watch-ignore' : 'watch'))
         <span>{{ $label }}</span>
     </a>
@@ -10,10 +15,10 @@
         <input type="hidden" name="type" value="{{ $entity->getMorphClass() }}">
         <input type="hidden" name="id" value="{{ $entity->id }}">
 
-        <ul refs="dropdown@menu" class="dropdown-menu xl-limited anchor-left pb-none">
+        <ul refs="dropdown@menu" class="dropdown-menu xl-limited anchor-left pb-none" role="menu">
             @foreach(\BookStack\Activity\WatchLevels::allSuitedFor($entity) as $option => $value)
                 <li>
-                    <button name="level" value="{{ $option }}" class="icon-item">
+                    <button name="level" value="{{ $option }}" class="icon-item" role="menuitem">
                         @if($watchLevel === $option)
                             <span class="text-pos pt-m"
                                   title="{{ trans('common.status_active') }}">@icon('check-circle')</span>
@@ -32,12 +37,13 @@
                         </div>
                     </button>
                 </li>
-                <li>
+                <li role="presentation">
                     <hr class="my-none">
                 </li>
             @endforeach
             <li>
                 <a href="{{ url('/my-account/notifications') }}"
+                   role="menuitem"
                    target="_blank"
                    class="text-item text-muted text-small break-text">{{ trans('entities.watch_change_default') }}</a>
             </li>
diff --git a/resources/views/exports/book.blade.php b/resources/views/exports/book.blade.php
index 9de7b8eba..8935c5151 100644
--- a/resources/views/exports/book.blade.php
+++ b/resources/views/exports/book.blade.php
@@ -5,7 +5,7 @@
 @section('content')
 
     <h1 style="font-size: 4.8em">{{$book->name}}</h1>
-    <div>{!! $book->descriptionHtml() !!}</div>
+    <div>{!! $book->descriptionInfo()->getHtml() !!}</div>
 
     @include('exports.parts.book-contents-menu', ['children' => $bookChildren])
 
diff --git a/resources/views/exports/chapter.blade.php b/resources/views/exports/chapter.blade.php
index 515366d60..46a073887 100644
--- a/resources/views/exports/chapter.blade.php
+++ b/resources/views/exports/chapter.blade.php
@@ -5,7 +5,7 @@
 @section('content')
 
     <h1 style="font-size: 4.8em">{{$chapter->name}}</h1>
-    <div>{!! $chapter->descriptionHtml() !!}</div>
+    <div>{!! $chapter->descriptionInfo()->getHtml() !!}</div>
 
     @include('exports.parts.chapter-contents-menu', ['pages' => $pages])
 
diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php
index a28b79bb3..1c46b7a0b 100644
--- a/resources/views/exports/import-show.blade.php
+++ b/resources/views/exports/import-show.blade.php
@@ -26,7 +26,7 @@
                     </div>
                     <div class="text-right text-muted">
                         <div>{{ trans('entities.import_size', ['size' => $import->getSizeString()]) }}</div>
-                        <div><span title="{{ $import->created_at->toISOString() }}">{{ trans('entities.import_uploaded_at', ['relativeTime' => $import->created_at->diffForHumans()]) }}</span></div>
+                        <div><span title="{{ $dates->absolute($import->created_at) }}">{{ trans('entities.import_uploaded_at', ['relativeTime' => $dates->relative($import->created_at)]) }}</span></div>
                         @if($import->createdBy)
                             <div>
                                 {{ trans('entities.import_uploaded_by') }}
diff --git a/resources/views/exports/parts/chapter-item.blade.php b/resources/views/exports/parts/chapter-item.blade.php
index fa0b1f228..beb3b8abb 100644
--- a/resources/views/exports/parts/chapter-item.blade.php
+++ b/resources/views/exports/parts/chapter-item.blade.php
@@ -1,7 +1,7 @@
 <div class="page-break"></div>
 <h1 id="chapter-{{$chapter->id}}">{{ $chapter->name }}</h1>
 
-<div>{!! $chapter->descriptionHtml() !!}</div>
+<div>{!! $chapter->descriptionInfo()->getHtml() !!}</div>
 
 @if(count($chapter->visible_pages) > 0)
     @foreach($chapter->visible_pages as $page)
diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php
index 2f7659c46..dc287370e 100644
--- a/resources/views/exports/parts/import.blade.php
+++ b/resources/views/exports/parts/import.blade.php
@@ -5,6 +5,6 @@
     </div>
     <div class="px-m py-s flex-container-row gap-m items-center">
         <div class="bold opacity-80 text-muted">{{ $import->getSizeString() }}</div>
-        <div class="bold opacity-80 text-muted min-width-xs text-right" title="{{ $import->created_at->toISOString() }}">@icon('time'){{ $import->created_at->diffForHumans() }}</div>
+        <div class="bold opacity-80 text-muted min-width-xs text-right" title="{{ $dates->absolute($import->created_at) }}">@icon('time'){{ $dates->relative($import->created_at) }}</div>
     </div>
 </div>
\ No newline at end of file
diff --git a/resources/views/exports/parts/meta.blade.php b/resources/views/exports/parts/meta.blade.php
index d4128898b..00117f4a1 100644
--- a/resources/views/exports/parts/meta.blade.php
+++ b/resources/views/exports/parts/meta.blade.php
@@ -4,13 +4,13 @@
     @endif
 
     @icon('star'){!! trans('entities.meta_created' . ($entity->createdBy ? '_name' : ''), [
-        'timeLength' => $entity->created_at->isoFormat('D MMMM Y HH:mm:ss'),
+        'timeLength' => $dates->absolute($entity->created_at),
         'user' => e($entity->createdBy->name ?? ''),
         ]) !!}
     <br>
 
     @icon('edit'){!! trans('entities.meta_updated' . ($entity->updatedBy ? '_name' : ''), [
-            'timeLength' => $entity->updated_at->isoFormat('D MMMM Y HH:mm:ss'),
+            'timeLength' => $dates->absolute($entity->updated_at),
             'user' => e($entity->updatedBy->name ?? '')
         ]) !!}
 </div>
\ No newline at end of file
diff --git a/resources/views/form/description-html-input.blade.php b/resources/views/form/description-html-input.blade.php
index 3cf726ba4..983d2fb83 100644
--- a/resources/views/form/description-html-input.blade.php
+++ b/resources/views/form/description-html-input.blade.php
@@ -1,8 +1,7 @@
 <textarea component="wysiwyg-input"
-          option:wysiwyg-input:language="{{ $locale->htmlLang() }}"
           option:wysiwyg-input:text-direction="{{ $locale->htmlDirection() }}"
           id="description_html" name="description_html" rows="5"
-          @if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionHtml()}}@endif</textarea>
+          @if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionInfo()->getHtml() }}@endif</textarea>
 @if($errors->has('description_html'))
     <div class="text-neg text-small">{{ $errors->first('description_html') }}</div>
 @endif
\ No newline at end of file
diff --git a/resources/views/home/books.blade.php b/resources/views/home/books.blade.php
index a2f2bf796..afd3f30fc 100644
--- a/resources/views/home/books.blade.php
+++ b/resources/views/home/books.blade.php
@@ -12,7 +12,7 @@
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-link">
-            @if(user()->can('book-create-all'))
+            @if(userCan(\BookStack\Permissions\Permission::BookCreateAll))
                 <a href="{{ url("/create-book") }}" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.books_create') }}</span>
diff --git a/resources/views/home/shelves.blade.php b/resources/views/home/shelves.blade.php
index 1265db29e..7a4d002a0 100644
--- a/resources/views/home/shelves.blade.php
+++ b/resources/views/home/shelves.blade.php
@@ -12,7 +12,7 @@
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-link">
-            @if(user()->can('bookshelf-create-all'))
+            @if(userCan(\BookStack\Permissions\Permission::BookshelfCreateAll))
                 <a href="{{ url("/create-shelf") }}" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.shelves_new_action') }}</span>
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index a6d908fc0..b868cb888 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -29,7 +29,7 @@
     <link rel="icon" type="image/png" sizes="32x32" href="{{ setting('app-icon-32') ?: url('/icon-32.png') }}">
 
     <!-- PWA -->
-    <link rel="manifest" href="{{ url('/manifest.json') }}">
+    <link rel="manifest" href="{{ url('/manifest.json') }}" crossorigin="use-credentials">
     <meta name="mobile-web-app-capable" content="yes">
 
     <!-- OpenSearch -->
diff --git a/resources/views/layouts/parts/custom-styles.blade.php b/resources/views/layouts/parts/custom-styles.blade.php
index bfdcc8512..b31334e73 100644
--- a/resources/views/layouts/parts/custom-styles.blade.php
+++ b/resources/views/layouts/parts/custom-styles.blade.php
@@ -1,15 +1,22 @@
-@php
-    $settingSuffix = setting()->getForCurrentUser('dark-mode-enabled') ? '-dark' : '';
-@endphp
 <style>
     :root {
-        --color-primary: {{ setting('app-color' . $settingSuffix) }};
-        --color-primary-light: {{ setting('app-color-light' . $settingSuffix) }};
-        --color-link: {{ setting('link-color' . $settingSuffix) }};
-        --color-bookshelf: {{ setting('bookshelf-color' . $settingSuffix)}};
-        --color-book: {{ setting('book-color' . $settingSuffix)}};
-        --color-chapter: {{ setting('chapter-color' . $settingSuffix)}};
-        --color-page: {{ setting('page-color' . $settingSuffix)}};
-        --color-page-draft: {{ setting('page-draft-color' . $settingSuffix)}};
+        --color-primary: {{ setting('app-color') }};
+        --color-primary-light: {{ setting('app-color-light') }};
+        --color-link: {{ setting('link-color') }};
+        --color-bookshelf: {{ setting('bookshelf-color') }};
+        --color-book: {{ setting('book-color') }};
+        --color-chapter: {{ setting('chapter-color') }};
+        --color-page: {{ setting('page-color') }};
+        --color-page-draft: {{ setting('page-draft-color') }};
+    }
+    :root.dark-mode {
+        --color-primary: {{ setting('app-color-dark') }};
+        --color-primary-light: {{ setting('app-color-light-dark') }};
+        --color-link: {{ setting('link-color-dark') }};
+        --color-bookshelf: {{ setting('bookshelf-color-dark') }};
+        --color-book: {{ setting('book-color-dark') }};
+        --color-chapter: {{ setting('chapter-color-dark') }};
+        --color-page: {{ setting('page-color-dark') }};
+        --color-page-draft: {{ setting('page-draft-color-dark') }};
     }
 </style>
diff --git a/resources/views/layouts/parts/header-links.blade.php b/resources/views/layouts/parts/header-links.blade.php
index 697f406c3..c3d2f58da 100644
--- a/resources/views/layouts/parts/header-links.blade.php
+++ b/resources/views/layouts/parts/header-links.blade.php
@@ -2,16 +2,16 @@
 
 @if (user()->hasAppAccess())
     <a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
-    @if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
+    @if(userCanOnAny(\BookStack\Permissions\Permission::View, \BookStack\Entities\Models\Bookshelf::class) || userCan(\BookStack\Permissions\Permission::BookshelfViewAll) || userCan(\BookStack\Permissions\Permission::BookshelfViewOwn))
         <a href="{{ url('/shelves') }}"
            data-shortcut="shelves_view">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
     @endif
     <a href="{{ url('/books') }}" data-shortcut="books_view">@icon('books'){{ trans('entities.books') }}</a>
-    @if(!user()->isGuest() && userCan('settings-manage'))
+    @if(!user()->isGuest() && userCan(\BookStack\Permissions\Permission::SettingsManage))
         <a href="{{ url('/settings') }}"
            data-shortcut="settings_view">@icon('settings'){{ trans('settings.settings') }}</a>
     @endif
-    @if(!user()->isGuest() && userCan('users-manage') && !userCan('settings-manage'))
+    @if(!user()->isGuest() && userCan(\BookStack\Permissions\Permission::UsersManage) && !userCan(\BookStack\Permissions\Permission::SettingsManage))
         <a href="{{ url('/settings/users') }}"
            data-shortcut="settings_view">@icon('users'){{ trans('settings.users') }}</a>
     @endif
diff --git a/resources/views/layouts/parts/header-user-menu.blade.php b/resources/views/layouts/parts/header-user-menu.blade.php
index db4820a4d..c252deb82 100644
--- a/resources/views/layouts/parts/header-user-menu.blade.php
+++ b/resources/views/layouts/parts/header-user-menu.blade.php
@@ -1,33 +1,43 @@
 <div class="dropdown-container" component="dropdown" option:dropdown:bubble-escapes="true">
-    <span class="user-name py-s hide-under-l" refs="dropdown@toggle"
-          aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.profile_menu') }}" tabindex="0">
+    <button class="user-name py-s hide-under-l" refs="dropdown@toggle"
+          aria-haspopup="menu"
+          aria-expanded="false"
+          aria-label="{{ trans('common.profile_menu') }}">
         <img class="avatar" src="{{$user->getAvatar(30)}}" alt="{{ $user->name }}">
         <span class="name">{{ $user->getShortName(9) }}</span> @icon('caret-down')
-    </span>
-    <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
+    </button>
+    <ul refs="dropdown@menu" class="dropdown-menu" role="menu" aria-label="{{ trans('common.profile_menu') }}">
         <li>
-            <a href="{{ url('/favourites') }}" data-shortcut="favourites_view" class="icon-item">
+            <a href="{{ url('/favourites') }}"
+               role="menuitem"
+               data-shortcut="favourites_view"
+               class="icon-item">
                 @icon('star')
                 <div>{{ trans('entities.my_favourites') }}</div>
             </a>
         </li>
         <li>
-            <a href="{{ $user->getProfileUrl() }}" data-shortcut="profile_view" class="icon-item">
+            <a href="{{ $user->getProfileUrl() }}"
+               role="menuitem"
+               data-shortcut="profile_view"
+               class="icon-item">
                 @icon('user')
                 <div>{{ trans('common.view_profile') }}</div>
             </a>
         </li>
         <li>
-            <a href="{{ url('/my-account') }}" class="icon-item">
+            <a href="{{ url('/my-account') }}"
+               role="menuitem"
+               class="icon-item">
                 @icon('user-preferences')
                 <div>{{ trans('preferences.my_account') }}</div>
             </a>
         </li>
-        <li><hr></li>
+        <li role="presentation"><hr></li>
         <li>
-            @include('common.dark-mode-toggle', ['classes' => 'icon-item'])
+            @include('common.dark-mode-toggle', ['classes' => 'icon-item', 'buttonRole' => 'menuitem'])
         </li>
-        <li><hr></li>
+        <li role="presentation"><hr></li>
         <li>
             @php
                 $logoutPath = match (config('auth.method')) {
@@ -38,7 +48,7 @@
             @endphp
             <form action="{{ url($logoutPath) }}" method="post">
                 {{ csrf_field() }}
-                <button class="icon-item" data-shortcut="logout">
+                <button class="icon-item" role="menuitem" data-shortcut="logout">
                     @icon('logout')
                     <div>{{ trans('auth.logout') }}</div>
                 </button>
diff --git a/resources/views/layouts/tri.blade.php b/resources/views/layouts/tri.blade.php
index c3cedf0fb..061cc6994 100644
--- a/resources/views/layouts/tri.blade.php
+++ b/resources/views/layouts/tri.blade.php
@@ -28,15 +28,15 @@
     <div refs="tri-layout@container" class="tri-layout-container" @yield('container-attrs') >
 
         <div class="tri-layout-sides print-hidden">
-            <div class="tri-layout-sides-content">
+            <div refs="tri-layout@sidebar-scroll-container" class="tri-layout-sides-content">
                 <div class="tri-layout-right print-hidden">
-                    <aside class="tri-layout-right-contents">
+                    <aside refs="tri-layout@sidebar-scroll-container" class="tri-layout-right-contents">
                         @yield('right')
                     </aside>
                 </div>
 
                 <div class="tri-layout-left print-hidden" id="sidebar">
-                    <aside class="tri-layout-left-contents">
+                    <aside refs="tri-layout@sidebar-scroll-container" class="tri-layout-left-contents">
                         @yield('left')
                     </aside>
                 </div>
diff --git a/resources/views/misc/opensearch.blade.php b/resources/views/misc/opensearch.blade.php
index 5c1503fc4..3d3a0ec5f 100644
--- a/resources/views/misc/opensearch.blade.php
+++ b/resources/views/misc/opensearch.blade.php
@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+@php echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n"; @endphp
 <OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
   <ShortName>{{ mb_strimwidth(setting('app-name'), 0, 16) }}</ShortName>
   <Description>{{ trans('common.opensearch_description', ['appName' => setting('app-name')]) }}</Description>
diff --git a/resources/views/pages/parts/code-editor.blade.php b/resources/views/pages/parts/code-editor.blade.php
index fc8852a32..8fa433738 100644
--- a/resources/views/pages/parts/code-editor.blade.php
+++ b/resources/views/pages/parts/code-editor.blade.php
@@ -13,7 +13,7 @@
                     </button>
                     <ul refs="dropdown@menu code-editor@historyList" class="dropdown-menu"></ul>
                 </div>
-                <button class="popup-header-close" refs="popup@hide">@icon('close')</button>
+                <button class="popup-header-close" refs="popup@hide" title="{{ trans('common.close') }}">@icon('close')</button>
             </div>
 
             <div class="code-editor-body-wrap flex-container-row flex-fill">
@@ -23,7 +23,7 @@
                     <div refs="code-editor@language-options-container" class="lang-options">
                         @php
                             $languages = [
-                                'Bash', 'CSS', 'C', 'C++', 'C#', 'Clojure', 'Dart', 'Diff', 'Fortran', 'F#', 'Go', 'Haskell', 'HTML', 'INI',
+                                'Bash', 'CSS', 'C', 'C++', 'C#', 'Clojure', 'Dart', 'Diff', 'Fortran', 'F#', 'Go', 'Groovy', 'Haskell', 'HTML', 'INI',
                                 'Java', 'JavaScript', 'JSON', 'Julia', 'Kotlin', 'LaTeX', 'Lua', 'MarkDown', 'MATLAB', 'MSSQL', 'MySQL',
                                 'Nginx', 'OCaml', 'Octave', 'Pascal', 'Perl', 'PHP', 'PL/SQL', 'PostgreSQL', 'Powershell', 'Python',
                                 'R', 'Ruby', 'Rust', 'SAS', 'Scala', 'Scheme', 'Shell', 'Smarty', 'SQL', 'SQLite', 'Swift',
@@ -34,8 +34,8 @@
                         @foreach($languages as $language)
                             <div class="relative">
                                 <button type="button" refs="code-editor@language-button" data-favourite="false" data-lang="{{ strtolower($language) }}">{{ $language }}</button>
-                                <button class="lang-option-favorite-toggle action-favourite" data-title="{{ trans('common.favourite') }}">@icon('star-outline')</button>
-                                <button class="lang-option-favorite-toggle action-unfavourite" data-title="{{ trans('common.unfavourite') }}">@icon('star')</button>
+                                <button class="lang-option-favorite-toggle action-favourite" title="{{ trans('common.favourite') }}">@icon('star-outline')</button>
+                                <button class="lang-option-favorite-toggle action-unfavourite" title="{{ trans('common.unfavourite') }}">@icon('star')</button>
                             </div>
                         @endforeach
                     </div>
diff --git a/resources/views/pages/parts/editor-toolbar.blade.php b/resources/views/pages/parts/editor-toolbar.blade.php
index 341fbf67d..a3ea58374 100644
--- a/resources/views/pages/parts/editor-toolbar.blade.php
+++ b/resources/views/pages/parts/editor-toolbar.blade.php
@@ -14,7 +14,7 @@
             <div class="flex-container-row items-center justify-center">
                 <button type="button"
                         refs="dropdown@toggle"
-                        aria-haspopup="true"
+                        aria-haspopup="menu"
                         aria-expanded="false"
                         title="{{ trans('entities.pages_edit_draft_options') }}"
                         class="text-link icon-list-item">
@@ -25,38 +25,38 @@
             </div>
             <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
                 <li>
-                    <button refs="page-editor@saveDraft" type="button" class="text-pos icon-item">
+                    <button refs="page-editor@saveDraft" type="button" role="menuitem" class="text-pos icon-item">
                         @icon('save')
                         <div>{{ trans('entities.pages_edit_save_draft') }}</div>
                     </button>
                 </li>
                 @if($isDraft)
                     <li>
-                        <a href="{{ $model->getUrl('/delete') }}" class="text-neg icon-item">
+                        <a href="{{ $model->getUrl('/delete') }}" role="menuitem" class="text-neg icon-item">
                             @icon('delete')
                             {{ trans('entities.pages_edit_delete_draft') }}
                         </a>
                     </li>
                 @endif
                 <li refs="page-editor@discard-draft-wrap" {{ $isDraftRevision ? '' : 'hidden' }}>
-                    <button refs="page-editor@discard-draft" type="button" class="text-warn icon-item">
+                    <button refs="page-editor@discard-draft" type="button" role="menuitem" class="text-warn icon-item">
                         @icon('cancel')
                         <div>{{ trans('entities.pages_edit_discard_draft') }}</div>
                     </button>
                 </li>
                 <li refs="page-editor@delete-draft-wrap" {{ $isDraftRevision ? '' : 'hidden' }}>
-                    <button refs="page-editor@delete-draft" type="button" class="text-neg icon-item">
+                    <button refs="page-editor@delete-draft" type="button" role="menuitem" class="text-neg icon-item">
                         @icon('delete')
                         <div>{{ trans('entities.pages_edit_delete_draft') }}</div>
                     </button>
                 </li>
-                @if(userCan('editor-change'))
-                    <li>
+                @if(userCan(\BookStack\Permissions\Permission::EditorChange))
+                    <li role="presentation">
                         <hr>
                     </li>
                     <li>
                         @if($editor !== \BookStack\Entities\Tools\PageEditorType::Markdown)
-                            <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-clean" refs="page-editor@changeEditor" class="icon-item">
+                            <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-clean" refs="page-editor@changeEditor" role="menuitem" class="icon-item">
                                 @icon('swap-horizontal')
                                 <div>
                                     {{ trans('entities.pages_edit_switch_to_markdown') }}
@@ -64,7 +64,7 @@
                                     <small>{{ trans('entities.pages_edit_switch_to_markdown_clean') }}</small>
                                 </div>
                             </a>
-                            <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-stable" refs="page-editor@changeEditor" class="icon-item">
+                            <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-stable" refs="page-editor@changeEditor" role="menuitem" class="icon-item">
                                 @icon('swap-horizontal')
                                 <div>
                                     {{ trans('entities.pages_edit_switch_to_markdown') }}
@@ -74,13 +74,13 @@
                             </a>
                         @endif
                         @if($editor !== \BookStack\Entities\Tools\PageEditorType::WysiwygTinymce)
-                            <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=wysiwyg" refs="page-editor@changeEditor" class="icon-item">
+                            <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=wysiwyg" refs="page-editor@changeEditor" role="menuitem" class="icon-item">
                                 @icon('swap-horizontal')
                                 <div>{{ trans('entities.pages_edit_switch_to_wysiwyg') }}</div>
                             </a>
                         @endif
                         @if($editor !== \BookStack\Entities\Tools\PageEditorType::WysiwygLexical)
-                            <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=wysiwyg2024" refs="page-editor@changeEditor" class="icon-item">
+                            <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=wysiwyg2024" refs="page-editor@changeEditor" role="menuitem" class="icon-item">
                                 @icon('swap-horizontal')
                                 <div>
                                     {{ trans('entities.pages_edit_switch_to_new_wysiwyg') }}
@@ -104,13 +104,18 @@
                 <span refs="page-editor@changelogDisplay">{{ trans('entities.pages_edit_set_changelog') }}</span>
             </button>
             <ul refs="dropdown@menu" class="wide dropdown-menu">
-                <li class="px-l py-m">
+                <li class="px-m py-s">
                     <p class="text-muted pb-s">{{ trans('entities.pages_edit_enter_changelog_desc') }}</p>
-                    <input refs="page-editor@changelogInput"
-                           name="summary"
-                           id="summary-input"
-                           type="text"
-                           placeholder="{{ trans('entities.pages_edit_enter_changelog') }}" />
+                    <textarea
+                        refs="page-editor@changelogInput"
+                        name="summary"
+                        id="summary-input"
+                        rows="2"
+                        maxlength="180"
+                        title="{{ trans('entities.pages_edit_enter_changelog') }}"
+                        placeholder="{{ trans('entities.pages_edit_enter_changelog') }}"
+                    ></textarea>
+                    <small refs="page-editor@changelogCounter" class="text-muted mt-xs">0 / 180</small>
                 </li>
             </ul>
             <span>{{-- Prevents button jumping on menu show --}}</span>
diff --git a/resources/views/pages/parts/editor-toolbox.blade.php b/resources/views/pages/parts/editor-toolbox.blade.php
index 4ecac26a5..8b7a68a9a 100644
--- a/resources/views/pages/parts/editor-toolbox.blade.php
+++ b/resources/views/pages/parts/editor-toolbox.blade.php
@@ -4,7 +4,7 @@
         <div class="tabs-inner flex-container-column justify-center">
             <button type="button" refs="editor-toolbox@toggle" title="{{ trans('entities.toggle_sidebar') }}" aria-expanded="false" class="toolbox-toggle">@icon('caret-left-circle')</button>
             <button type="button" refs="editor-toolbox@tab-button" data-tab="tags" title="{{ trans('entities.page_tags') }}" class="active">@icon('tag')</button>
-            @if(userCan('attachment-create-all'))
+            @if(userCan(\BookStack\Permissions\Permission::AttachmentCreateAll))
                 <button type="button" refs="editor-toolbox@tab-button" data-tab="files" title="{{ trans('entities.attachments') }}">@icon('attach')</button>
             @endif
             <button type="button" refs="editor-toolbox@tab-button" data-tab="templates" title="{{ trans('entities.templates') }}">@icon('template')</button>
@@ -21,7 +21,7 @@
         </div>
     </div>
 
-    @if(userCan('attachment-create-all'))
+    @if(userCan(\BookStack\Permissions\Permission::AttachmentCreateAll))
         @include('attachments.manager', ['page' => $page])
     @endif
 
diff --git a/resources/views/pages/parts/image-manager-form.blade.php b/resources/views/pages/parts/image-manager-form.blade.php
index bd84e247d..4d675b81e 100644
--- a/resources/views/pages/parts/image-manager-form.blade.php
+++ b/resources/views/pages/parts/image-manager-form.blade.php
@@ -38,17 +38,17 @@
             <input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}">
         </div>
         <div class="flex-container-row justify-space-between gap-m">
-            @if(userCan('image-delete', $image) || userCan('image-update', $image))
+            @if(userCan(\BookStack\Permissions\Permission::ImageDelete, $image) || userCan(\BookStack\Permissions\Permission::ImageUpdate, $image))
                 <div component="dropdown"
                      class="dropdown-container">
                     <button refs="dropdown@toggle" type="button" class="button icon outline">@icon('more')</button>
                     <div refs="dropdown@menu" class="dropdown-menu anchor-left">
-                        @if(userCan('image-delete', $image))
+                        @if(userCan(\BookStack\Permissions\Permission::ImageDelete, $image))
                             <button type="button"
                                     id="image-manager-delete"
                                     class="text-item">{{ trans('common.delete') }}</button>
                         @endif
-                        @if(userCan('image-update', $image))
+                        @if(userCan(\BookStack\Permissions\Permission::ImageUpdate, $image))
                             <button type="button"
                                     id="image-manager-replace"
                                     refs="dropzone@select-button"
@@ -94,18 +94,18 @@
 
     <div class="text-muted text-small">
         <hr class="my-m">
-        <div title="{{ $image->created_at->format('Y-m-d H:i:s') }}">
-            @icon('star') {{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->diffForHumans()]) }}
+        <div title="{{ $dates->absolute($image->created_at) }}">
+            @icon('star') {{ trans('components.image_uploaded', ['uploadedDate' => $dates->relative($image->created_at)]) }}
         </div>
         @if($image->created_at->valueOf() !== $image->updated_at->valueOf())
-            <div title="{{ $image->updated_at->format('Y-m-d H:i:s') }}">
-                @icon('edit') {{ trans('components.image_updated', ['updateDate' => $image->updated_at->diffForHumans()]) }}
+            <div title="{{ $dates->absolute($image->updated_at) }}">
+                @icon('edit') {{ trans('components.image_updated', ['updateDate' => $dates->relative($image->updated_at)]) }}
             </div>
         @endif
         @if($image->createdBy)
             <div>@icon('user') {{ trans('components.image_uploaded_by', ['userName' => $image->createdBy->name]) }}</div>
         @endif
-        @if(($page = $image->getPage()) && userCan('view', $page))
+        @if(($page = $image->getPage()) && userCan(\BookStack\Permissions\Permission::PageView, $page))
             <div>
                 @icon('page')
                 {!! trans('components.image_uploaded_to', [
diff --git a/resources/views/pages/parts/markdown-editor.blade.php b/resources/views/pages/parts/markdown-editor.blade.php
index ac62443f9..5b1761b76 100644
--- a/resources/views/pages/parts/markdown-editor.blade.php
+++ b/resources/views/pages/parts/markdown-editor.blade.php
@@ -26,6 +26,10 @@
                     <div class="px-m">
                         @include('form.custom-checkbox', ['name' => 'md-scrollSync', 'label' => trans('entities.pages_md_sync_scroll'), 'value' => true, 'checked' => true])
                     </div>
+                    <hr class="m-none">
+                    <div class="px-m">
+                        @include('form.custom-checkbox', ['name' => 'md-plainEditor', 'label' => trans('entities.pages_md_plain_editor'), 'value' => true, 'checked' => false])
+                    </div>
                 </div>
             </div>
         </div>
diff --git a/resources/views/pages/parts/pointer.blade.php b/resources/views/pages/parts/pointer.blade.php
index 56f36cb75..6a40c86f0 100644
--- a/resources/views/pages/parts/pointer.blade.php
+++ b/resources/views/pages/parts/pointer.blade.php
@@ -6,29 +6,36 @@
          tabindex="-1"
          aria-label="{{ trans('entities.pages_pointer_label') }}"
          class="pointer-container">
-        <div class="pointer flex-container-row items-center justify-space-between p-s anim {{ userCan('page-update', $page) ? 'is-page-editable' : ''}}" >
-            <div refs="pointer@mode-section" class="flex-container-row items-center gap-s">
+        <div class="pointer flex-container-row items-center justify-space-between gap-xs p-xs anim" >
+            <div refs="pointer@mode-section" class="flex flex-container-row items-center gap-xs">
                 <button refs="pointer@mode-toggle"
                         title="{{ trans('entities.pages_pointer_toggle_link') }}"
                         class="text-button icon px-xs">@icon('link')</button>
-                <div class="input-group">
+                <div class="input-group flex flex-container-row items-center">
                     <input refs="pointer@link-input" aria-label="{{ trans('entities.pages_pointer_permalink') }}" readonly="readonly" type="text" id="pointer-url" placeholder="url">
-                    <button refs="pointer@link-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+                    <button refs="pointer@link-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
                 </div>
             </div>
-            <div refs="pointer@mode-section" hidden class="flex-container-row items-center gap-s">
+            <div refs="pointer@mode-section" hidden class="flex flex-container-row items-center gap-xs">
                 <button refs="pointer@mode-toggle"
                         title="{{ trans('entities.pages_pointer_toggle_include') }}"
                         class="text-button icon px-xs">@icon('include')</button>
-                <div class="input-group">
+                <div class="input-group flex flex-container-row items-center">
                     <input refs="pointer@include-input" aria-label="{{ trans('entities.pages_pointer_include_tag') }}" readonly="readonly" type="text" id="pointer-include" placeholder="include">
-                    <button refs="pointer@include-button" class="button outline icon" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
+                    <button refs="pointer@include-button" class="button outline icon px-xs" type="button" title="{{ trans('entities.pages_copy_link') }}">@icon('copy')</button>
                 </div>
             </div>
-            @if(userCan('page-update', $page))
-                <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
-                   class="button primary outline icon heading-edit-icon ml-s px-s" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
-            @endif
+            <div>
+                @if(userCan(\BookStack\Permissions\Permission::PageUpdate, $page))
+                    <a href="{{ $page->getUrl('/edit') }}" id="pointer-edit" data-edit-href="{{ $page->getUrl('/edit') }}"
+                       class="button primary outline icon heading-edit-icon px-xs" title="{{ trans('entities.pages_edit_content_link')}}">@icon('edit')</a>
+                @endif
+                @if($commentTree->enabled() && userCan(\BookStack\Permissions\Permission::CommentCreateAll))
+                    <button type="button"
+                            refs="pointer@comment-button"
+                            class="button primary outline icon px-xs m-none" title="{{ trans('entities.comment_add')}}">@icon('comment')</button>
+                @endif
+            </div>
         </div>
     </div>
 
diff --git a/resources/views/pages/parts/revisions-index-row.blade.php b/resources/views/pages/parts/revisions-index-row.blade.php
index 48bea5b57..749cfb763 100644
--- a/resources/views/pages/parts/revisions-index-row.blade.php
+++ b/resources/views/pages/parts/revisions-index-row.blade.php
@@ -17,8 +17,8 @@
                 @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif
                 <br>
                 <div class="text-muted">
-                    <small>{{ $revision->created_at->isoFormat('D MMMM Y HH:mm:ss') }}</small>
-                    <small>({{ $revision->created_at->diffForHumans() }})</small>
+                    <small>{{ $dates->absolute($revision->created_at) }}</small>
+                    <small>({{ $dates->relative($revision->created_at) }})</small>
                 </div>
             </div>
         </div>
@@ -38,7 +38,7 @@
         @else
             <a href="{{ $revision->getUrl() }}" target="_blank" rel="noopener">{{ trans('entities.pages_revisions_preview') }}</a>
 
-            @if(userCan('page-update', $revision->page))
+            @if(userCan(\BookStack\Permissions\Permission::PageUpdate, $revision->page))
                 <span class="text-muted opacity-70">&nbsp;|&nbsp;</span>
                 <div component="dropdown" class="dropdown-container">
                     <a refs="dropdown@toggle" href="#" aria-haspopup="true" aria-expanded="false">{{ trans('entities.pages_revisions_restore') }}</a>
@@ -58,7 +58,7 @@
                 </div>
             @endif
 
-            @if(userCan('page-delete', $revision->page))
+            @if(userCan(\BookStack\Permissions\Permission::PageDelete, $revision->page))
                 <span class="text-muted opacity-70">&nbsp;|&nbsp;</span>
                 <div component="dropdown" class="dropdown-container">
                     <a refs="dropdown@toggle" href="#" aria-haspopup="true" aria-expanded="false">{{ trans('common.delete') }}</a>
diff --git a/resources/views/pages/parts/template-manager-list.blade.php b/resources/views/pages/parts/template-manager-list.blade.php
index f2f70c142..990583a71 100644
--- a/resources/views/pages/parts/template-manager-list.blade.php
+++ b/resources/views/pages/parts/template-manager-list.blade.php
@@ -6,7 +6,7 @@
          draggable="true" template-id="{{ $template->id }}">
         <div class="template-item-content" title="{{ trans('entities.templates_replace_content') }}">
             <div>{{ $template->name }}</div>
-            <div class="text-muted">{{ trans('entities.meta_updated', ['timeLength' => $template->updated_at->diffForHumans()]) }}</div>
+            <div class="text-muted" title="{{ $dates->absolute($template->updated_at) }}">{{ trans('entities.meta_updated', ['timeLength' => $dates->relative($template->updated_at)]) }}</div>
         </div>
         <div class="template-item-actions">
             <button type="button"
diff --git a/resources/views/pages/parts/template-manager.blade.php b/resources/views/pages/parts/template-manager.blade.php
index ee4467552..fffc81b56 100644
--- a/resources/views/pages/parts/template-manager.blade.php
+++ b/resources/views/pages/parts/template-manager.blade.php
@@ -1,5 +1,5 @@
 <div component="template-manager">
-    @if(userCan('templates-manage'))
+    @if(userCan(\BookStack\Permissions\Permission::TemplatesManage))
         <p class="text-muted small mb-none">
             {{ trans('entities.templates_explain_set_as_template') }}
         </p>
diff --git a/resources/views/pages/parts/toolbox-comments.blade.php b/resources/views/pages/parts/toolbox-comments.blade.php
index d632b85c6..72958a2fe 100644
--- a/resources/views/pages/parts/toolbox-comments.blade.php
+++ b/resources/views/pages/parts/toolbox-comments.blade.php
@@ -1,3 +1,6 @@
+{{--
+$comments - CommentTree
+--}}
 <div refs="editor-toolbox@tab-content" data-tab-content="comments" class="toolbox-tab-content">
     <h4>{{ trans('entities.comments') }}</h4>
 
@@ -5,11 +8,19 @@
         <p class="text-muted small mb-m">
             {{ trans('entities.comment_editor_explain') }}
         </p>
-        @foreach($comments->get() as $branch)
+        @foreach($comments->getActive() as $branch)
             @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true])
         @endforeach
         @if($comments->empty())
-            <p class="italic text-muted">{{ trans('common.no_items') }}</p>
+            <p class="italic text-muted">{{ trans('entities.comment_none') }}</p>
+        @endif
+        @if($comments->archivedThreadCount() > 0)
+            <details class="section-expander mt-s">
+                <summary>{{ trans('entities.comment_archived_threads') }}</summary>
+                @foreach($comments->getArchived() as $branch)
+                    @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => true])
+                @endforeach
+            </details>
         @endif
     </div>
 </div>
\ No newline at end of file
diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php
index e3a31dd5e..fcec90157 100644
--- a/resources/views/pages/show.blade.php
+++ b/resources/views/pages/show.blade.php
@@ -28,12 +28,6 @@
     @include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous])
 
     @if ($commentTree->enabled())
-        @if(($previous || $next))
-            <div class="px-xl print-hidden">
-                <hr class="darker">
-            </div>
-        @endif
-
         <div class="comments-container mb-l print-hidden">
             @include('comments.comments', ['commentTree' => $commentTree, 'page' => $page])
             <div class="clearfix"></div>
@@ -85,7 +79,7 @@
 
             @if($book->hasPermissions())
                 <div class="active-restriction">
-                    @if(userCan('restrictions-manage', $book))
+                    @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $book))
                         <a href="{{ $book->getUrl('/permissions') }}" class="entity-meta-item">
                             @icon('lock')
                             <div>{{ trans('entities.books_permissions_active') }}</div>
@@ -101,7 +95,7 @@
 
             @if($page->chapter && $page->chapter->hasPermissions())
                 <div class="active-restriction">
-                    @if(userCan('restrictions-manage', $page->chapter))
+                    @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $page->chapter))
                         <a href="{{ $page->chapter->getUrl('/permissions') }}" class="entity-meta-item">
                             @icon('lock')
                             <div>{{ trans('entities.chapters_permissions_active') }}</div>
@@ -117,7 +111,7 @@
 
             @if($page->hasPermissions())
                 <div class="active-restriction">
-                    @if(userCan('restrictions-manage', $page))
+                    @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $page))
                         <a href="{{ $page->getUrl('/permissions') }}" class="entity-meta-item">
                             @icon('lock')
                             <div>{{ trans('entities.pages_permissions_active') }}</div>
@@ -146,20 +140,20 @@
         <div class="icon-list text-link">
 
             {{--User Actions--}}
-            @if(userCan('page-update', $page))
+            @if(userCan(\BookStack\Permissions\Permission::PageUpdate, $page))
                 <a href="{{ $page->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
                 </a>
             @endif
-            @if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCanOnAny('create', \BookStack\Entities\Models\Chapter::class) || userCan('page-create-all') || userCan('page-create-own'))
+            @if(userCan(\BookStack\Permissions\Permission::PageCreateAll) || userCan(\BookStack\Permissions\Permission::PageCreateOwn) || userCanOnAny(\BookStack\Permissions\Permission::Create, \BookStack\Entities\Models\Book::class) || userCanOnAny(\BookStack\Permissions\Permission::Create, \BookStack\Entities\Models\Chapter::class))
                 <a href="{{ $page->getUrl('/copy') }}" data-shortcut="copy" class="icon-list-item">
                     <span>@icon('copy')</span>
                     <span>{{ trans('common.copy') }}</span>
                 </a>
             @endif
-            @if(userCan('page-update', $page))
-                @if(userCan('page-delete', $page))
+            @if(userCan(\BookStack\Permissions\Permission::PageUpdate, $page))
+                @if(userCan(\BookStack\Permissions\Permission::PageDelete, $page))
 	                <a href="{{ $page->getUrl('/move') }}" data-shortcut="move" class="icon-list-item">
 	                    <span>@icon('folder')</span>
 	                    <span>{{ trans('common.move') }}</span>
@@ -170,13 +164,13 @@
                 <span>@icon('history')</span>
                 <span>{{ trans('entities.revisions') }}</span>
             </a>
-            @if(userCan('restrictions-manage', $page))
+            @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $page))
                 <a href="{{ $page->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
                     <span>@icon('lock')</span>
                     <span>{{ trans('entities.permissions') }}</span>
                 </a>
             @endif
-            @if(userCan('page-delete', $page))
+            @if(userCan(\BookStack\Permissions\Permission::PageDelete, $page))
                 <a href="{{ $page->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
                     <span>@icon('delete')</span>
                     <span>{{ trans('common.delete') }}</span>
@@ -191,7 +185,7 @@
             @if(!user()->isGuest())
                 @include('entities.favourite-action', ['entity' => $page])
             @endif
-            @if(userCan('content-export'))
+            @if(userCan(\BookStack\Permissions\Permission::ContentExport))
                 @include('entities.export-menu', ['entity' => $page])
             @endif
         </div>
diff --git a/resources/views/search/all.blade.php b/resources/views/search/all.blade.php
index ad437604b..48250816f 100644
--- a/resources/views/search/all.blade.php
+++ b/resources/views/search/all.blade.php
@@ -87,11 +87,7 @@
                         @include('entities.list', ['entities' => $entities, 'showPath' => true, 'showTags' => true])
                     </div>
 
-                    @if($hasNextPage)
-                        <div class="text-right mt-m">
-                            <a href="{{ $nextPageLink }}" class="button outline">{{ trans('entities.search_more') }}</a>
-                        </div>
-                    @endif
+                    {{ $paginator->render() }}
                 </div>
             </div>
         </div>
diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php
index 8e4776680..0407275e0 100644
--- a/resources/views/settings/audit.blade.php
+++ b/resources/views/settings/audit.blade.php
@@ -88,7 +88,11 @@
                 @foreach($activities as $activity)
                     <div class="item-list-row flex-container-row items-center wrap py-xxs">
                         <div class="flex-2 px-m py-xxs flex-container-row items-center min-width-m">
-                            @include('settings.parts.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
+                            @if($activity->user && $activity->user->created_at <= $activity->created_at)
+                                @include('settings.parts.table-user', ['user' => $activity->user])
+                            @else
+                                [ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }}
+                            @endif
                         </div>
                         <div class="flex-2 px-m py-xxs min-width-m"><strong
                                     class="mr-xs hide-over-m">{{ trans('settings.audit_table_event') }}
diff --git a/resources/views/settings/categories/customization.blade.php b/resources/views/settings/categories/customization.blade.php
index 70a490298..732cb0198 100644
--- a/resources/views/settings/categories/customization.blade.php
+++ b/resources/views/settings/categories/customization.blade.php
@@ -32,7 +32,7 @@
                     <select name="setting-app-editor" id="setting-app-editor">
                         <option @if(setting('app-editor') === 'wysiwyg') selected @endif value="wysiwyg">WYSIWYG</option>
                         <option @if(setting('app-editor') === 'markdown') selected @endif value="markdown">Markdown</option>
-                        <option @if(setting('app-editor') === 'wysiwyg2024') selected @endif value="wysiwyg2024">New WYSIWYG (alpha testing)</option>
+                        <option @if(setting('app-editor') === 'wysiwyg2024') selected @endif value="wysiwyg2024">New WYSIWYG (beta testing)</option>
                     </select>
                 </div>
             </div>
diff --git a/resources/views/settings/categories/features.blade.php b/resources/views/settings/categories/features.blade.php
index 5935e21f5..d451a0ba3 100644
--- a/resources/views/settings/categories/features.blade.php
+++ b/resources/views/settings/categories/features.blade.php
@@ -13,7 +13,7 @@
                 <div>
                     <label for="setting-app-public" class="setting-list-label">{{ trans('settings.app_public_access') }}</label>
                     <p class="small">{!! trans('settings.app_public_access_desc') !!}</p>
-                    @if(userCan('users-manage'))
+                    @if(userCan(\BookStack\Permissions\Permission::UsersManage))
                         <p class="small mb-none">
                             <a href="{{ url($guestUser->getEditUrl()) }}">{!! trans('settings.app_public_access_desc_guest') !!}</a>
                         </p>
diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php
index 9d1d9814b..4aff11bd2 100644
--- a/resources/views/settings/categories/sorting.blade.php
+++ b/resources/views/settings/categories/sorting.blade.php
@@ -10,6 +10,40 @@
         {{ csrf_field() }}
         <input type="hidden" name="section" value="sorting">
 
+        <div class="grid half gap-xl items-center">
+            <div>
+                <label for="setting-sorting-shelves-per-page"
+                    class="setting-list-label">{{ trans('settings.sorting_shelves_per_page') }}</label>
+                <p class="small">{{ trans('settings.sorting_shelves_per_page_desc') }}</p>
+            </div>
+            <div>
+                <input type="number"
+                    id="setting-sorting-shelves-per-page"
+                    name="setting-sorting-shelves-per-page"
+                    value="{{ intval(setting('sorting-shelves-per-page', 18)) }}"
+                    min="1"
+                    step="1"
+                    class="@if($errors->has('setting-sorting-shelves-per-page')) neg @endif">
+            </div>
+        </div>
+
+        <div class="grid half gap-xl items-center">
+            <div>
+                <label for="setting-sorting-books-per-page"
+                    class="setting-list-label">{{ trans('settings.sorting_books_per_page') }}</label>
+                <p class="small">{{ trans('settings.sorting_books_per_page_desc') }}</p>
+            </div>
+            <div>
+                <input type="number"
+                    id="setting-sorting-books-per-page"
+                    name="setting-sorting-books-per-page"
+                    value="{{ intval(setting('sorting-books-per-page', 18)) }}"
+                    min="1"
+                    step="1"
+                    class="@if($errors->has('setting-sorting-books-per-page')) neg @endif">
+            </div>
+        </div>
+
         <div class="setting-list">
             <div class="grid half gap-xl items-center">
                 <div>
diff --git a/resources/views/settings/parts/navbar.blade.php b/resources/views/settings/parts/navbar.blade.php
index e229f5e65..cda8bdae0 100644
--- a/resources/views/settings/parts/navbar.blade.php
+++ b/resources/views/settings/parts/navbar.blade.php
@@ -1,19 +1,19 @@
 
 <nav class="active-link-list py-m flex-container-row justify-center wrap">
-    @if(userCan('settings-manage'))
+    @if(userCan(\BookStack\Permissions\Permission::SettingsManage))
         <a href="{{ url('/settings') }}" @if($selected == 'settings') class="active" @endif>@icon('settings'){{ trans('settings.settings') }}</a>
         <a href="{{ url('/settings/maintenance') }}" @if($selected == 'maintenance') class="active" @endif>@icon('spanner'){{ trans('settings.maint') }}</a>
     @endif
-    @if(userCan('settings-manage') && userCan('users-manage'))
+    @if(userCan(\BookStack\Permissions\Permission::SettingsManage) && userCan(\BookStack\Permissions\Permission::UsersManage))
         <a href="{{ url('/settings/audit') }}" @if($selected == 'audit') class="active" @endif>@icon('open-book'){{ trans('settings.audit') }}</a>
     @endif
-    @if(userCan('users-manage'))
+    @if(userCan(\BookStack\Permissions\Permission::UsersManage))
         <a href="{{ url('/settings/users') }}" @if($selected == 'users') class="active" @endif>@icon('users'){{ trans('settings.users') }}</a>
     @endif
-    @if(userCan('user-roles-manage'))
+    @if(userCan(\BookStack\Permissions\Permission::UserRolesManage))
         <a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</a>
     @endif
-    @if(userCan('settings-manage'))
+    @if(userCan(\BookStack\Permissions\Permission::SettingsManage))
         <a href="{{ url('/settings/webhooks') }}" @if($selected == 'webhooks') class="active" @endif>@icon('webhooks'){{ trans('settings.webhooks') }}</a>
     @endif
 </nav>
\ No newline at end of file
diff --git a/resources/views/settings/parts/table-user.blade.php b/resources/views/settings/parts/table-user.blade.php
index d29ad1979..affc7b6c4 100644
--- a/resources/views/settings/parts/table-user.blade.php
+++ b/resources/views/settings/parts/table-user.blade.php
@@ -1,12 +1,7 @@
 {{--
-$user - User mode to display, Can be null.
-$user_id - Id of user to show. Must be provided.
+$user - User to display.
 --}}
-@if($user)
-    <a href="{{ $user->getEditUrl() }}" class="flex-container-row inline gap-s items-center">
-        <div class="flex-none"><img width="40" height="40" class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
-        <div class="flex">{{ $user->name }}</div>
-    </a>
-@else
-    [ID: {{ $user_id }}] {{ trans('common.deleted_user') }}
-@endif
\ No newline at end of file
+<a href="{{ $user->getEditUrl() }}" class="flex-container-row inline gap-s items-center">
+    <div class="flex-none"><img width="40" height="40" class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
+    <div class="flex">{{ $user->name }}</div>
+</a>
\ No newline at end of file
diff --git a/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php b/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php
index 8af598b1e..2dad617dc 100644
--- a/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php
+++ b/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php
@@ -33,15 +33,26 @@
         @endif
     </div>
     <div class="flex-2 px-m py-xs flex-container-row items-center min-width-m">
-        <div><strong class="hide-over-l">{{ trans('settings.recycle_bin_deleted_by') }}:<br></strong>@include('settings.parts.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</div>
+        <div>
+            <strong class="hide-over-l">{{ trans('settings.recycle_bin_deleted_by') }}:<br></strong>
+            @if($deletion->deleter)
+                @include('settings.parts.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])
+            @else
+                {{ trans('common.deleted_user') }}
+            @endif
+        </div>
     </div>
     <div class="flex px-m py-xs min-width-s"><strong class="hide-over-l">{{ trans('settings.recycle_bin_deleted_at') }}:<br></strong>{{ $deletion->created_at }}</div>
     <div class="flex px-m py-xs text-m-right min-width-s">
         <div component="dropdown" class="dropdown-container">
-            <button type="button" refs="dropdown@toggle" class="button outline">{{ trans('common.actions') }}</button>
-            <ul refs="dropdown@menu" class="dropdown-menu">
-                <li><a class="text-item" href="{{ $deletion->getUrl('/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
-                <li><a class="text-item" href="{{ $deletion->getUrl('/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
+            <button type="button"
+                    refs="dropdown@toggle"
+                    aria-haspopup="menu"
+                    aria-expanded="false"
+                    class="button outline">{{ trans('common.actions') }}</button>
+            <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
+                <li><a class="text-item" href="{{ $deletion->getUrl('/restore') }}" role="menuitem">{{ trans('settings.recycle_bin_restore') }}</a></li>
+                <li><a class="text-item" href="{{ $deletion->getUrl('/destroy') }}" role="menuitem">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
             </ul>
         </div>
     </div>
diff --git a/resources/views/settings/roles/edit.blade.php b/resources/views/settings/roles/edit.blade.php
index bd1dbb763..da7835e1a 100644
--- a/resources/views/settings/roles/edit.blade.php
+++ b/resources/views/settings/roles/edit.blade.php
@@ -35,11 +35,11 @@
                                 <img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}">
                             </div>
                             <div>
-                                @if(userCan('users-manage') || user()->id == $user->id)
+                                @if(userCan(\BookStack\Permissions\Permission::UsersManage) || user()->id == $user->id)
                                     <a href="{{ url("/settings/users/{$user->id}") }}">
                                         @endif
                                         {{ $user->name }}
-                                        @if(userCan('users-manage') || user()->id == $user->id)
+                                        @if(userCan(\BookStack\Permissions\Permission::UsersManage) || user()->id == $user->id)
                                     </a>
                                 @endif
                             </div>
diff --git a/resources/views/settings/roles/index.blade.php b/resources/views/settings/roles/index.blade.php
index 27ee9ce3f..3ccdd9b85 100644
--- a/resources/views/settings/roles/index.blade.php
+++ b/resources/views/settings/roles/index.blade.php
@@ -24,6 +24,7 @@
                         <form method="get" action="{{ url("/settings/roles") }}">
                             <input type="text"
                                    name="search"
+                                   title="{{ trans('common.search') }}"
                                    placeholder="{{ trans('common.search') }}"
                                    value="{{ $listOptions->getSearch() }}">
                         </form>
diff --git a/resources/views/settings/webhooks/edit.blade.php b/resources/views/settings/webhooks/edit.blade.php
index 680fc1207..22d0c0183 100644
--- a/resources/views/settings/webhooks/edit.blade.php
+++ b/resources/views/settings/webhooks/edit.blade.php
@@ -14,9 +14,17 @@
                 <div>
                     <label class="setting-list-label">{{ trans('settings.webhooks_status') }}</label>
                     <p class="mb-none">
-                        {{ trans('settings.webhooks_last_called') }} {{ $webhook->last_called_at ? $webhook->last_called_at->diffForHumans() : trans('common.never') }}
+                        @if($webhook->last_called_at)
+                            <span title="{{ $dates->absolute($webhook->last_called_at) }}">{{ trans('settings.webhooks_last_called') }} {{  $dates->relative($webhook->last_called_at) }}</span>
+                        @else
+                            <span>{{ trans('settings.webhooks_last_called') }} {{ trans('common.never') }}</span>
+                        @endif
                         <br>
-                        {{ trans('settings.webhooks_last_errored') }} {{ $webhook->last_errored_at ? $webhook->last_errored_at->diffForHumans() : trans('common.never') }}
+                        @if($webhook->last_errored_at)
+                            <span title="{{ $dates->absolute($webhook->last_errored_at) }}">{{ trans('settings.webhooks_last_errored') }} {{  $dates->relative($webhook->last_errored_at) }}</span>
+                        @else
+                            <span>{{ trans('settings.webhooks_last_errored') }} {{ trans('common.never') }}</span>
+                        @endif
                     </p>
                 </div>
                 <div class="text-muted">
diff --git a/resources/views/shelves/index.blade.php b/resources/views/shelves/index.blade.php
index 85663b413..bb7c57e0f 100644
--- a/resources/views/shelves/index.blade.php
+++ b/resources/views/shelves/index.blade.php
@@ -9,7 +9,7 @@
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-link">
-            @if(userCan('bookshelf-create-all'))
+            @if(userCan(\BookStack\Permissions\Permission::BookshelfCreateAll))
                 <a href="{{ url("/create-shelf") }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.shelves_new_action') }}</span>
diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php
index 7790ba5a4..0a5343b16 100644
--- a/resources/views/shelves/parts/form.blade.php
+++ b/resources/views/shelves/parts/form.blade.php
@@ -1,7 +1,3 @@
-@push('head')
-    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
-@endpush
-
 {{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
@@ -30,9 +26,12 @@
                     @icon('more')
                 </button>
                 <div refs="dropdown@menu shelf-sort@sort-button-container" class="dropdown-menu" role="menu">
-                    <button type="button" class="text-item" data-sort="name">{{ trans('entities.books_sort_name') }}</button>
-                    <button type="button" class="text-item" data-sort="created">{{ trans('entities.books_sort_created') }}</button>
-                    <button type="button" class="text-item" data-sort="updated">{{ trans('entities.books_sort_updated') }}</button>
+                    <button type="button" class="text-item"
+                            data-sort="name">{{ trans('entities.books_sort_name') }}</button>
+                    <button type="button" class="text-item"
+                            data-sort="created">{{ trans('entities.books_sort_created') }}</button>
+                    <button type="button" class="text-item"
+                            data-sort="updated">{{ trans('entities.books_sort_updated') }}</button>
                 </div>
             </div>
         </div>
@@ -46,7 +45,8 @@
     </div>
     <div class="form-group">
         <label for="books" id="shelf-sort-all-books-label">{{ trans('entities.shelves_add_books') }}</label>
-        <input type="text" refs="shelf-sort@book-search" class="scroll-box-search" placeholder="{{ trans('common.search') }}">
+        <input type="text" refs="shelf-sort@book-search" class="scroll-box-search"
+               placeholder="{{ trans('common.search') }}">
         <ul refs="shelf-sort@all-book-list"
             aria-labelledby="shelf-sort-all-books-label"
             class="scroll-box available-option-list">
@@ -58,7 +58,6 @@
 </div>
 
 
-
 <div class="form-group collapsible" component="collapsible" id="logo-control">
     <button refs="collapsible@trigger" type="button" class="collapse-title text-link" aria-expanded="false">
         <label>{{ trans('common.cover_image') }}</label>
@@ -68,7 +67,7 @@
 
         @include('form.image-picker', [
             'defaultImage' => url('/book_default_cover.png'),
-            'currentImage' => (isset($shelf) && $shelf->cover) ? $shelf->getBookCover() : url('/book_default_cover.png') ,
+            'currentImage' => (($shelf ?? null)?->coverInfo()->getUrl(440, 250, null) ?? url('/book_default_cover.png')),
             'name' => 'image',
             'imageClass' => 'cover'
         ])
@@ -85,7 +84,8 @@
 </div>
 
 <div class="form-group text-right">
-    <a href="{{ isset($shelf) ? $shelf->getUrl() : url('/shelves') }}" class="button outline">{{ trans('common.cancel') }}</a>
+    <a href="{{ isset($shelf) ? $shelf->getUrl() : url('/shelves') }}"
+       class="button outline">{{ trans('common.cancel') }}</a>
     <button type="submit" class="button">{{ trans('entities.shelves_save') }}</button>
 </div>
 
diff --git a/resources/views/shelves/parts/list-item.blade.php b/resources/views/shelves/parts/list-item.blade.php
index 00cacfa70..5fc8a362b 100644
--- a/resources/views/shelves/parts/list-item.blade.php
+++ b/resources/views/shelves/parts/list-item.blade.php
@@ -1,5 +1,5 @@
 <a href="{{ $shelf->getUrl() }}" class="shelf entity-list-item" data-entity-type="bookshelf" data-entity-id="{{$shelf->id}}">
-    <div class="entity-list-item-image bg-bookshelf @if($shelf->image_id) has-image @endif" style="background-image: url('{{ $shelf->getBookCover() }}')">
+    <div class="entity-list-item-image bg-bookshelf @if($shelf->coverInfo()->exists()) has-image @endif" style="background-image: url('{{ $shelf->coverInfo()->getUrl() }}')">
         @icon('bookshelf')
     </div>
     <div class="content py-xs">
diff --git a/resources/views/shelves/parts/list.blade.php b/resources/views/shelves/parts/list.blade.php
index 7f6e02a8b..5be5a7bb6 100644
--- a/resources/views/shelves/parts/list.blade.php
+++ b/resources/views/shelves/parts/list.blade.php
@@ -29,7 +29,7 @@
         </div>
     @else
         <p class="text-muted">{{ trans('entities.shelves_empty') }}</p>
-        @if(userCan('bookshelf-create-all'))
+        @if(userCan(\BookStack\Permissions\Permission::BookshelfCreateAll))
             <div class="icon-list block inline">
                 <a href="{{ url("/create-shelf") }}"
                    class="icon-list-item text-bookshelf">
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php
index 11baccaf4..9ee14f1bf 100644
--- a/resources/views/shelves/show.blade.php
+++ b/resources/views/shelves/show.blade.php
@@ -2,8 +2,8 @@
 
 @push('social-meta')
     <meta property="og:description" content="{{ Str::limit($shelf->description, 100, '...') }}">
-    @if($shelf->cover)
-        <meta property="og:image" content="{{ $shelf->getBookCover() }}">
+    @if($shelf->coverInfo()->exists())
+        <meta property="og:image" content="{{ $shelf->coverInfo()->getUrl() }}">
     @endif
 @endpush
 
@@ -28,7 +28,7 @@
         </div>
 
         <div class="book-content">
-            <div class="text-muted break-text">{!! $shelf->descriptionHtml() !!}</div>
+            <div class="text-muted break-text">{!! $shelf->descriptionInfo()->getHtml() !!}</div>
             @if(count($sortedVisibleShelfBooks) > 0)
                 @if($view === 'list')
                     <div class="entity-list">
@@ -48,13 +48,13 @@
                     <hr>
                     <p class="text-muted italic mt-xl mb-m">{{ trans('entities.shelves_empty_contents') }}</p>
                     <div class="icon-list inline block">
-                        @if(userCan('book-create-all') && userCan('bookshelf-update', $shelf))
+                        @if(userCan(\BookStack\Permissions\Permission::BookCreateAll) && userCan(\BookStack\Permissions\Permission::BookshelfUpdate, $shelf))
                             <a href="{{ $shelf->getUrl('/create-book') }}" class="icon-list-item text-book">
                                 <span class="icon">@icon('add')</span>
                                 <span>{{ trans('entities.books_create') }}</span>
                             </a>
                         @endif
-                        @if(userCan('bookshelf-update', $shelf))
+                        @if(userCan(\BookStack\Permissions\Permission::BookshelfUpdate, $shelf))
                             <a href="{{ $shelf->getUrl('/edit') }}" class="icon-list-item text-bookshelf">
                                 <span class="icon">@icon('edit')</span>
                                 <span>{{ trans('entities.shelves_edit_and_assign') }}</span>
@@ -82,7 +82,7 @@
             @include('entities.meta', ['entity' => $shelf, 'watchOptions' => null])
             @if($shelf->hasPermissions())
                 <div class="active-restriction">
-                    @if(userCan('restrictions-manage', $shelf))
+                    @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $shelf))
                         <a href="{{ $shelf->getUrl('/permissions') }}" class="entity-meta-item">
                             @icon('lock')
                             <div>{{ trans('entities.shelves_permissions_active') }}</div>
@@ -111,7 +111,7 @@
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-link">
 
-            @if(userCan('book-create-all') && userCan('bookshelf-update', $shelf))
+            @if(userCan(\BookStack\Permissions\Permission::BookCreateAll) && userCan(\BookStack\Permissions\Permission::BookshelfUpdate, $shelf))
                 <a href="{{ $shelf->getUrl('/create-book') }}" data-shortcut="new" class="icon-list-item">
                     <span class="icon">@icon('add')</span>
                     <span>{{ trans('entities.books_new_action') }}</span>
@@ -122,21 +122,21 @@
 
             <hr class="primary-background">
 
-            @if(userCan('bookshelf-update', $shelf))
+            @if(userCan(\BookStack\Permissions\Permission::BookshelfUpdate, $shelf))
                 <a href="{{ $shelf->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
                 </a>
             @endif
 
-            @if(userCan('restrictions-manage', $shelf))
+            @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $shelf))
                 <a href="{{ $shelf->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
                     <span>@icon('lock')</span>
                     <span>{{ trans('entities.permissions') }}</span>
                 </a>
             @endif
 
-            @if(userCan('bookshelf-delete', $shelf))
+            @if(userCan(\BookStack\Permissions\Permission::BookshelfDelete, $shelf))
                 <a href="{{ $shelf->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
                     <span>@icon('delete')</span>
                     <span>{{ trans('common.delete') }}</span>
diff --git a/resources/views/users/account/auth.blade.php b/resources/views/users/account/auth.blade.php
index d6f85093b..57e6c1f9c 100644
--- a/resources/views/users/account/auth.blade.php
+++ b/resources/views/users/account/auth.blade.php
@@ -81,7 +81,7 @@
         </section>
     @endif
 
-    @if(userCan('access-api'))
+    @if(userCan(\BookStack\Permissions\Permission::AccessApi))
         @include('users.api-tokens.parts.list', ['user' => user(), 'context' => 'my-account'])
     @endif
 @stop
diff --git a/resources/views/users/account/delete.blade.php b/resources/views/users/account/delete.blade.php
index 75698d919..aa051e9ad 100644
--- a/resources/views/users/account/delete.blade.php
+++ b/resources/views/users/account/delete.blade.php
@@ -12,7 +12,7 @@
 
             <p>{{ trans('preferences.delete_my_account_desc') }}</p>
 
-            @if(userCan('users-manage'))
+            @if(userCan(\BookStack\Permissions\Permission::UsersManage))
                 <hr class="my-l">
 
                 <div class="grid half gap-xl v-center">
diff --git a/resources/views/users/account/layout.blade.php b/resources/views/users/account/layout.blade.php
index f54a51c5a..df8ebc2d9 100644
--- a/resources/views/users/account/layout.blade.php
+++ b/resources/views/users/account/layout.blade.php
@@ -12,7 +12,7 @@
                         <a href="{{ url('/my-account/profile') }}" class="{{ $category === 'profile' ? 'active' : '' }}">@icon('user') {{ trans('preferences.profile') }}</a>
                         <a href="{{ url('/my-account/auth') }}" class="{{ $category === 'auth' ? 'active' : '' }}">@icon('security') {{ trans('preferences.auth') }}</a>
                         <a href="{{ url('/my-account/shortcuts') }}" class="{{ $category === 'shortcuts' ? 'active' : '' }}">@icon('shortcuts') {{ trans('preferences.shortcuts_interface') }}</a>
-                        @if(userCan('receive-notifications'))
+                        @if(userCan(\BookStack\Permissions\Permission::ReceiveNotifications))
                             <a href="{{ url('/my-account/notifications') }}" class="{{ $category === 'notifications' ? 'active' : '' }}">@icon('notifications') {{ trans('preferences.notifications') }}</a>
                         @endif
                     </nav>
diff --git a/resources/views/users/account/profile.blade.php b/resources/views/users/account/profile.blade.php
index 617c09723..778e35885 100644
--- a/resources/views/users/account/profile.blade.php
+++ b/resources/views/users/account/profile.blade.php
@@ -35,10 +35,10 @@
                             <p class="text-small mb-none">{{ trans('preferences.profile_email_desc') }}</p>
                         </div>
                         <div class="flex stretch-inputs">
-                            @include('form.text', ['name' => 'email', 'disabled' => !userCan('users-manage')])
+                            @include('form.text', ['name' => 'email', 'disabled' => !userCan(\BookStack\Permissions\Permission::UsersManage)])
                         </div>
                     </div>
-                    @if(!userCan('users-manage'))
+                    @if(!userCan(\BookStack\Permissions\Permission::UsersManage))
                         <p class="text-small text-muted">{{ trans('preferences.profile_email_no_permission') }}</p>
                     @endif
                 </div>
@@ -75,7 +75,7 @@
         </form>
     </section>
 
-    @if(userCan('users-manage'))
+    @if(userCan(\BookStack\Permissions\Permission::UsersManage))
         <section class="card content-wrap auto-height">
             <div class="flex-container-row gap-l items-center wrap">
                 <div class="flex">
diff --git a/resources/views/users/api-tokens/edit.blade.php b/resources/views/users/api-tokens/edit.blade.php
index aa3e49ded..3a1ff49d3 100644
--- a/resources/views/users/api-tokens/edit.blade.php
+++ b/resources/views/users/api-tokens/edit.blade.php
@@ -42,12 +42,12 @@
                 <div class="grid half gap-xl v-center">
 
                     <div class="text-muted text-small">
-                        <span title="{{ $token->created_at }}">
-                            {{ trans('settings.user_api_token_created', ['timeAgo' => $token->created_at->diffForHumans()]) }}
+                        <span title="{{ $dates->absolute($token->created_at) }}">
+                            {{ trans('settings.user_api_token_created', ['timeAgo' => $dates->relative($token->created_at)]) }}
                         </span>
                         <br>
-                        <span title="{{ $token->updated_at }}">
-                            {{ trans('settings.user_api_token_updated', ['timeAgo' => $token->created_at->diffForHumans()]) }}
+                        <span title="{{ $dates->absolute($token->updated_at) }}">
+                            {{ trans('settings.user_api_token_updated', ['timeAgo' => $dates->relative($token->created_at)]) }}
                         </span>
                     </div>
 
diff --git a/resources/views/users/api-tokens/parts/list.blade.php b/resources/views/users/api-tokens/parts/list.blade.php
index 70aaa58f3..bd8dd53cc 100644
--- a/resources/views/users/api-tokens/parts/list.blade.php
+++ b/resources/views/users/api-tokens/parts/list.blade.php
@@ -2,7 +2,7 @@
     <div class="flex-container-row wrap justify-space-between items-center mb-s">
         <h2 class="list-heading">{{ trans('settings.users_api_tokens') }}</h2>
         <div class="text-right pt-xs">
-            @if(userCan('access-api'))
+            @if(userCan(\BookStack\Permissions\Permission::AccessApi))
                 <a href="{{ url('/api/docs') }}" class="button outline">{{ trans('settings.users_api_tokens_docs') }}</a>
                 <a href="{{ url('/api-tokens/' . $user->id . '/create?context=' . $context) }}" class="button outline">{{ trans('settings.users_api_tokens_create') }}</a>
             @endif
diff --git a/resources/views/users/create.blade.php b/resources/views/users/create.blade.php
index dafc623e1..daffc91a3 100644
--- a/resources/views/users/create.blade.php
+++ b/resources/views/users/create.blade.php
@@ -18,7 +18,7 @@
                 </div>
 
                 <div class="form-group text-right">
-                    <a href="{{  url(userCan('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{  url(userCan(\BookStack\Permissions\Permission::UsersManage) ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
                     <button class="button" type="submit">{{ trans('common.save') }}</button>
                 </div>
 
diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php
index 0dd607f8c..5d546076f 100644
--- a/resources/views/users/index.blade.php
+++ b/resources/views/users/index.blade.php
@@ -22,6 +22,7 @@
                         <form method="get" action="{{ url("/settings/users") }}">
                             <input type="text"
                                    name="search"
+                                   title="{{ trans('settings.users_search') }}"
                                    placeholder="{{ trans('settings.users_search') }}"
                                    value="{{ $listOptions->getSearch() }}">
                         </form>
diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php
index bf1eb08a7..86287646f 100644
--- a/resources/views/users/parts/form.blade.php
+++ b/resources/views/users/parts/form.blade.php
@@ -17,9 +17,9 @@
             @include('form.text', ['name' => 'name'])
         </div>
         <div>
-            @if($authMethod !== 'ldap' || userCan('users-manage'))
+            @if($authMethod !== 'ldap' || userCan(\BookStack\Permissions\Permission::UsersManage))
                 <label for="email">{{ trans('auth.email') }}</label>
-                @include('form.text', ['name' => 'email', 'disabled' => !userCan('users-manage')])
+                @include('form.text', ['name' => 'email', 'disabled' => !userCan(\BookStack\Permissions\Permission::UsersManage)])
             @endif
         </div>
     </div>
diff --git a/resources/views/users/parts/users-list-item.blade.php b/resources/views/users/parts/users-list-item.blade.php
index dc7c9f272..9c7ecd147 100644
--- a/resources/views/users/parts/users-list-item.blade.php
+++ b/resources/views/users/parts/users-list-item.blade.php
@@ -20,7 +20,7 @@
             @if($user->last_activity_at)
                 <small>{{ trans('settings.users_latest_activity') }}</small>
                 <br>
-                <small title="{{ $user->last_activity_at->format('Y-m-d H:i:s') }}">{{ $user->last_activity_at->diffForHumans() }}</small>
+                <small title="{{ $dates->absolute($user->last_activity_at) }}">{{ $dates->relative($user->last_activity_at) }}</small>
             @endif
         </div>
     </div>
diff --git a/resources/views/users/profile.blade.php b/resources/views/users/profile.blade.php
index a8be8a4c1..9879091a5 100644
--- a/resources/views/users/profile.blade.php
+++ b/resources/views/users/profile.blade.php
@@ -23,7 +23,7 @@
                             <div>
                                 <h4 class="mt-md">{{ $user->name }}</h4>
                                 <p class="text-muted">
-                                    {{ trans('entities.profile_user_for_x', ['time' => $user->created_at->diffForHumans(null, true)]) }}
+                                    {{ trans('entities.profile_user_for_x', ['time' => $dates->relative($user->created_at, false)]) }}
                                 </p>
                             </div>
                         </div>
diff --git a/routes/api.php b/routes/api.php
index 710364855..308a95d8c 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -2,12 +2,13 @@
 
 /**
  * Routes for the BookStack API.
- * Routes have a uri prefix of /api/.
+ * Routes have a URI prefix of /api/.
  * Controllers all end with "ApiController"
  */
 
-use BookStack\Activity\Controllers\AuditLogApiController;
+use BookStack\Activity\Controllers as ActivityControllers;
 use BookStack\Api\ApiDocsController;
+use BookStack\App\SystemApiController;
 use BookStack\Entities\Controllers as EntityControllers;
 use BookStack\Exports\Controllers as ExportControllers;
 use BookStack\Permissions\ContentPermissionApiController;
@@ -18,65 +19,85 @@ use BookStack\Users\Controllers\RoleApiController;
 use BookStack\Users\Controllers\UserApiController;
 use Illuminate\Support\Facades\Route;
 
-Route::get('docs.json', [ApiDocsController::class, 'json']);
+// Main Entity Routes
 
-Route::get('attachments', [AttachmentApiController::class, 'list']);
-Route::post('attachments', [AttachmentApiController::class, 'create']);
-Route::get('attachments/{id}', [AttachmentApiController::class, 'read']);
-Route::put('attachments/{id}', [AttachmentApiController::class, 'update']);
-Route::delete('attachments/{id}', [AttachmentApiController::class, 'delete']);
+Route::get('pages', [EntityControllers\PageApiController::class, 'list']);
+Route::post('pages', [EntityControllers\PageApiController::class, 'create']);
+Route::get('pages/{id}', [EntityControllers\PageApiController::class, 'read']);
+Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']);
+Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']);
+Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController::class, 'exportHtml']);
+Route::get('pages/{id}/export/pdf', [ExportControllers\PageExportApiController::class, 'exportPdf']);
+Route::get('pages/{id}/export/plaintext', [ExportControllers\PageExportApiController::class, 'exportPlainText']);
+Route::get('pages/{id}/export/markdown', [ExportControllers\PageExportApiController::class, 'exportMarkdown']);
+Route::get('pages/{id}/export/zip', [ExportControllers\PageExportApiController::class, 'exportZip']);
+
+Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']);
+Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']);
+Route::get('chapters/{id}', [EntityControllers\ChapterApiController::class, 'read']);
+Route::put('chapters/{id}', [EntityControllers\ChapterApiController::class, 'update']);
+Route::delete('chapters/{id}', [EntityControllers\ChapterApiController::class, 'delete']);
+Route::get('chapters/{id}/export/html', [ExportControllers\ChapterExportApiController::class, 'exportHtml']);
+Route::get('chapters/{id}/export/pdf', [ExportControllers\ChapterExportApiController::class, 'exportPdf']);
+Route::get('chapters/{id}/export/plaintext', [ExportControllers\ChapterExportApiController::class, 'exportPlainText']);
+Route::get('chapters/{id}/export/markdown', [ExportControllers\ChapterExportApiController::class, 'exportMarkdown']);
+Route::get('chapters/{id}/export/zip', [ExportControllers\ChapterExportApiController::class, 'exportZip']);
 
 Route::get('books', [EntityControllers\BookApiController::class, 'list']);
 Route::post('books', [EntityControllers\BookApiController::class, 'create']);
 Route::get('books/{id}', [EntityControllers\BookApiController::class, 'read']);
 Route::put('books/{id}', [EntityControllers\BookApiController::class, 'update']);
 Route::delete('books/{id}', [EntityControllers\BookApiController::class, 'delete']);
-
 Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController::class, 'exportHtml']);
 Route::get('books/{id}/export/pdf', [ExportControllers\BookExportApiController::class, 'exportPdf']);
 Route::get('books/{id}/export/plaintext', [ExportControllers\BookExportApiController::class, 'exportPlainText']);
 Route::get('books/{id}/export/markdown', [ExportControllers\BookExportApiController::class, 'exportMarkdown']);
+Route::get('books/{id}/export/zip', [ExportControllers\BookExportApiController::class, 'exportZip']);
 
-Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']);
-Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']);
-Route::get('chapters/{id}', [EntityControllers\ChapterApiController::class, 'read']);
-Route::put('chapters/{id}', [EntityControllers\ChapterApiController::class, 'update']);
-Route::delete('chapters/{id}', [EntityControllers\ChapterApiController::class, 'delete']);
-Route::get('chapters/{id}/export/html', [ExportControllers\ChapterExportApiController::class, 'exportHtml']);
-Route::get('chapters/{id}/export/pdf', [ExportControllers\ChapterExportApiController::class, 'exportPdf']);
-Route::get('chapters/{id}/export/plaintext', [ExportControllers\ChapterExportApiController::class, 'exportPlainText']);
-Route::get('chapters/{id}/export/markdown', [ExportControllers\ChapterExportApiController::class, 'exportMarkdown']);
+Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']);
+Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']);
+Route::get('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'read']);
+Route::put('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'update']);
+Route::delete('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'delete']);
 
-Route::get('pages', [EntityControllers\PageApiController::class, 'list']);
-Route::post('pages', [EntityControllers\PageApiController::class, 'create']);
-Route::get('pages/{id}', [EntityControllers\PageApiController::class, 'read']);
-Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']);
-Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']);
+// Additional Model Routes, in alphabetical order
 
-Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController::class, 'exportHtml']);
-Route::get('pages/{id}/export/pdf', [ExportControllers\PageExportApiController::class, 'exportPdf']);
-Route::get('pages/{id}/export/plaintext', [ExportControllers\PageExportApiController::class, 'exportPlainText']);
-Route::get('pages/{id}/export/markdown', [ExportControllers\PageExportApiController::class, 'exportMarkdown']);
+Route::get('attachments', [AttachmentApiController::class, 'list']);
+Route::post('attachments', [AttachmentApiController::class, 'create']);
+Route::get('attachments/{id}', [AttachmentApiController::class, 'read']);
+Route::put('attachments/{id}', [AttachmentApiController::class, 'update']);
+Route::delete('attachments/{id}', [AttachmentApiController::class, 'delete']);
+
+Route::get('audit-log', [ActivityControllers\AuditLogApiController::class, 'list']);
+
+Route::get('comments', [ActivityControllers\CommentApiController::class, 'list']);
+Route::post('comments', [ActivityControllers\CommentApiController::class, 'create']);
+Route::get('comments/{id}', [ActivityControllers\CommentApiController::class, 'read']);
+Route::put('comments/{id}', [ActivityControllers\CommentApiController::class, 'update']);
+Route::delete('comments/{id}', [ActivityControllers\CommentApiController::class, 'delete']);
+
+Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']);
+Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']);
+
+Route::get('docs.json', [ApiDocsController::class, 'json']);
 
 Route::get('image-gallery', [ImageGalleryApiController::class, 'list']);
 Route::post('image-gallery', [ImageGalleryApiController::class, 'create']);
+Route::get('image-gallery/url/data', [ImageGalleryApiController::class, 'readDataForUrl']);
 Route::get('image-gallery/{id}', [ImageGalleryApiController::class, 'read']);
+Route::get('image-gallery/{id}/data', [ImageGalleryApiController::class, 'readData']);
 Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']);
 Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']);
 
-Route::get('search', [SearchApiController::class, 'all']);
+Route::get('imports', [ExportControllers\ImportApiController::class, 'list']);
+Route::post('imports', [ExportControllers\ImportApiController::class, 'create']);
+Route::get('imports/{id}', [ExportControllers\ImportApiController::class, 'read']);
+Route::post('imports/{id}', [ExportControllers\ImportApiController::class, 'run']);
+Route::delete('imports/{id}', [ExportControllers\ImportApiController::class, 'delete']);
 
-Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']);
-Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']);
-Route::get('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'read']);
-Route::put('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'update']);
-Route::delete('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'delete']);
-
-Route::get('users', [UserApiController::class, 'list']);
-Route::post('users', [UserApiController::class, 'create']);
-Route::get('users/{id}', [UserApiController::class, 'read']);
-Route::put('users/{id}', [UserApiController::class, 'update']);
-Route::delete('users/{id}', [UserApiController::class, 'delete']);
+Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']);
+Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']);
+Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'destroy']);
 
 Route::get('roles', [RoleApiController::class, 'list']);
 Route::post('roles', [RoleApiController::class, 'create']);
@@ -84,11 +105,12 @@ Route::get('roles/{id}', [RoleApiController::class, 'read']);
 Route::put('roles/{id}', [RoleApiController::class, 'update']);
 Route::delete('roles/{id}', [RoleApiController::class, 'delete']);
 
-Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']);
-Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']);
-Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'destroy']);
+Route::get('search', [SearchApiController::class, 'all']);
 
-Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']);
-Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']);
+Route::get('system', [SystemApiController::class, 'read']);
 
-Route::get('audit-log', [AuditLogApiController::class, 'list']);
+Route::get('users', [UserApiController::class, 'list']);
+Route::post('users', [UserApiController::class, 'create']);
+Route::get('users/{id}', [UserApiController::class, 'read']);
+Route::put('users/{id}', [UserApiController::class, 'update']);
+Route::delete('users/{id}', [UserApiController::class, 'delete']);
diff --git a/routes/web.php b/routes/web.php
index 818472583..ea3efe1ac 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -179,6 +179,8 @@ Route::middleware('auth')->group(function () {
 
     // Comments
     Route::post('/comment/{pageId}', [ActivityControllers\CommentController::class, 'savePageComment']);
+    Route::put('/comment/{id}/archive', [ActivityControllers\CommentController::class, 'archive']);
+    Route::put('/comment/{id}/unarchive', [ActivityControllers\CommentController::class, 'unarchive']);
     Route::put('/comment/{id}', [ActivityControllers\CommentController::class, 'update']);
     Route::delete('/comment/{id}', [ActivityControllers\CommentController::class, 'destroy']);
 
diff --git a/tsconfig.json b/tsconfig.json
index 8bffc25f8..dacaefea2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,7 @@
   "include": ["resources/js/**/*"],
   "exclude": ["resources/js/wysiwyg/lexical/yjs/*"],
   "compilerOptions": {
-    "target": "es2019",
+    "target": "es2022",
     "module": "commonjs",
     "rootDir": "./resources/js/",
     "baseUrl": "./",
diff --git a/version b/version
index 51b8fdb60..085e9695f 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-v25.02-dev
+v25.11-dev



Test patch:
diff --git a/resources/js/services/__tests__/translations.test.ts b/resources/js/services/__tests__/translations.test.ts
index 043f1745f..5014051ab 100644
--- a/resources/js/services/__tests__/translations.test.ts
+++ b/resources/js/services/__tests__/translations.test.ts
@@ -58,6 +58,11 @@ describe('Translations Service', () => {
             expect(caseB).toEqual('an orange angry big dinosaur');
         });
 
+        test('it provides count as a replacement by default', () => {
+            const caseA = $trans.choice(`:count cats|:count dogs`, 4);
+            expect(caseA).toEqual('4 dogs');
+        });
+
         test('not provided replacements are left as-is', () => {
             const caseA = $trans.choice(`An :a dog`, 5, {});
             expect(caseA).toEqual('An :a dog');
diff --git a/resources/js/wysiwyg/api/__tests__/api-test-utils.ts b/resources/js/wysiwyg/api/__tests__/api-test-utils.ts
new file mode 100644
index 000000000..dacec3392
--- /dev/null
+++ b/resources/js/wysiwyg/api/__tests__/api-test-utils.ts
@@ -0,0 +1,14 @@
+import {createTestContext} from "lexical/__tests__/utils";
+import {EditorApi} from "../api";
+import {EditorUiContext} from "../../ui/framework/core";
+import {LexicalEditor} from "lexical";
+
+
+/**
+ * Create an instance of the EditorApi and EditorUiContext.
+ */
+export function createEditorApiInstance(): { api: EditorApi; context: EditorUiContext, editor: LexicalEditor} {
+    const context = createTestContext();
+    const api = new EditorApi(context);
+    return {api, context, editor: context.editor};
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/api/__tests__/content.test.ts b/resources/js/wysiwyg/api/__tests__/content.test.ts
new file mode 100644
index 000000000..0915b6685
--- /dev/null
+++ b/resources/js/wysiwyg/api/__tests__/content.test.ts
@@ -0,0 +1,93 @@
+import {createEditorApiInstance} from "./api-test-utils";
+import {$createParagraphNode, $createTextNode, $getRoot, IS_BOLD, LexicalEditor} from "lexical";
+import {expectNodeShapeToMatch} from "lexical/__tests__/utils";
+
+
+describe('Editor API: Content Module', () => {
+
+    describe('insertHtml()', () => {
+        it('should insert html at selection by default', () => {
+            const {api, editor} = createEditorApiInstance();
+            insertAndSelectSampleBlock(editor);
+
+            api.content.insertHtml('<strong>pp</strong>');
+            editor.commitUpdates();
+
+            expectNodeShapeToMatch(editor, [
+                {type: 'paragraph', children: [
+                    {text: 'He'},
+                    {text: 'pp', format: IS_BOLD},
+                    {text: 'o World'}
+                ]}
+            ]);
+        });
+
+        it('should handle a mix of inline and block elements', () => {
+            const {api, editor} = createEditorApiInstance();
+            insertAndSelectSampleBlock(editor);
+
+            api.content.insertHtml('<p>cat</p><strong>pp</strong><p>dog</p>');
+            editor.commitUpdates();
+
+            expectNodeShapeToMatch(editor, [
+                {type: 'paragraph', children: [{text: 'cat'}]},
+                {type: 'paragraph', children: [
+                        {text: 'He'},
+                        {text: 'pp', format: IS_BOLD},
+                        {text: 'o World'}
+                    ]},
+                {type: 'paragraph', children: [{text: 'dog'}]},
+            ]);
+        });
+
+        it('should throw and error if an invalid position is provided', () => {
+            const {api, editor} = createEditorApiInstance();
+            insertAndSelectSampleBlock(editor);
+
+
+            expect(() => {
+                api.content.insertHtml('happy<p>cat</p>', 'near-the-end');
+            }).toThrow('Invalid position: near-the-end. Valid positions are: start, end, selection');
+        });
+
+        it('should append html if end provided as a position', () => {
+            const {api, editor} = createEditorApiInstance();
+            insertAndSelectSampleBlock(editor);
+
+            api.content.insertHtml('happy<p>cat</p>', 'end');
+            editor.commitUpdates();
+
+            expectNodeShapeToMatch(editor, [
+                {type: 'paragraph', children: [{text: 'Hello World'}]},
+                {type: 'paragraph', children: [{text: 'happy'}]},
+                {type: 'paragraph', children: [{text: 'cat'}]},
+            ]);
+        });
+
+        it('should prepend html if start provided as a position', () => {
+            const {api, editor} = createEditorApiInstance();
+            insertAndSelectSampleBlock(editor);
+
+            api.content.insertHtml('happy<p>cat</p>', 'start');
+            editor.commitUpdates();
+
+            expectNodeShapeToMatch(editor, [
+                {type: 'paragraph', children: [{text: 'happy'}]},
+                {type: 'paragraph', children: [{text: 'cat'}]},
+                {type: 'paragraph', children: [{text: 'Hello World'}]},
+            ]);
+        });
+    });
+
+    function insertAndSelectSampleBlock(editor: LexicalEditor) {
+        editor.updateAndCommit(() => {
+            const p = $createParagraphNode();
+            const text = $createTextNode('Hello World');
+            p.append(text);
+            $getRoot().append(p);
+
+            text.select(2, 4);
+        });
+    }
+
+});
\ No newline at end of file
diff --git a/resources/js/wysiwyg/api/__tests__/ui.test.ts b/resources/js/wysiwyg/api/__tests__/ui.test.ts
new file mode 100644
index 000000000..80045bb48
--- /dev/null
+++ b/resources/js/wysiwyg/api/__tests__/ui.test.ts
@@ -0,0 +1,123 @@
+import {createEditorApiInstance} from "./api-test-utils";
+import {EditorApiButton, EditorApiToolbar, EditorApiToolbarSection} from "../ui";
+import {getMainEditorFullToolbar} from "../../ui/defaults/toolbars";
+import {EditorContainerUiElement} from "../../ui/framework/core";
+import {EditorOverflowContainer} from "../../ui/framework/blocks/overflow-container";
+
+
+describe('Editor API: UI Module', () => {
+
+    describe('createButton()', () => {
+        it('should return a button', () => {
+            const {api} = createEditorApiInstance();
+            const button = api.ui.createButton({label: 'Test', icon: 'test', action: () => ''});
+            expect(button).toBeInstanceOf(EditorApiButton);
+        });
+
+        it('should only need action to be required', () => {
+            const {api} = createEditorApiInstance();
+            const button = api.ui.createButton({action: () => ''});
+            expect(button).toBeInstanceOf(EditorApiButton);
+        });
+
+        it('should pass the label and icon to the button', () => {
+            const {api} = createEditorApiInstance();
+            const button = api.ui.createButton({label: 'TestLabel', icon: '<svg>cat</svg>', action: () => ''});
+            const html = button._getOriginalModel().getDOMElement().outerHTML;
+            expect(html).toContain('TestLabel');
+            expect(html).toContain('<svg>cat</svg>');
+        })
+    });
+
+    describe('EditorApiButton', () => {
+
+        describe('setActive()', () => {
+            it('should update the active state of the button', () => {
+                const {api} = createEditorApiInstance();
+                const button = api.ui.createButton({label: 'Test', icon: 'test', action: () => ''});
+
+                button.setActive(true);
+                expect(button._getOriginalModel().isActive()).toBe(true);
+
+                button.setActive(false);
+                expect(button._getOriginalModel().isActive()).toBe(false);
+            })
+        });
+
+        it('should call the provided action on click', () => {
+            const {api} = createEditorApiInstance();
+            let count = 0;
+            const button = api.ui.createButton({label: 'Test', icon: 'test', action: () => {
+                count++;
+            }});
+
+            const dom = button._getOriginalModel().getDOMElement();
+            dom.click();
+            dom.click();
+            expect(count).toBe(2);
+        });
+
+    });
+
+    describe('getMainToolbar()', () => {
+        it('should return the main editor toolbar', () => {
+            const {api, context} = createEditorApiInstance();
+            context.manager.setToolbar(getMainEditorFullToolbar(context));
+
+            const toolbar = api.ui.getMainToolbar();
+
+            expect(toolbar).toBeInstanceOf(EditorApiToolbar);
+        });
+    });
+
+    describe('EditorApiToolbar', () => {
+        describe('getSections()', () => {
+            it('should return the sections of the toolbar', () => {
+                const {api, context} = createEditorApiInstance();
+                context.manager.setToolbar(testToolbar());
+                const toolbar = api.ui.getMainToolbar();
+
+                const sections = toolbar?.getSections() || [];
+
+                expect(sections.length).toBe(2);
+                expect(sections[0]).toBeInstanceOf(EditorApiToolbarSection);
+            })
+        })
+    })
+
+    describe('EditorApiToolbarSection', () => {
+
+        describe('getLabel()', () => {
+            it('should return the label of the section', () => {
+                const {api, context} = createEditorApiInstance();
+                context.manager.setToolbar(testToolbar());
+                const section = api.ui.getMainToolbar()?.getSections()[0] as EditorApiToolbarSection;
+                expect(section.getLabel()).toBe('section-a');
+             })
+        });
+
+        describe('addButton()', () => {
+            it('should add a button to the section', () => {
+                const {api, context} = createEditorApiInstance();
+                const toolbar = testToolbar();
+                context.manager.setToolbar(toolbar);
+                const section = api.ui.getMainToolbar()?.getSections()[0] as EditorApiToolbarSection;
+
+                const button = api.ui.createButton({label: 'TestButtonText!', action: () => ''});
+                section.addButton(button);
+
+                const toolbarRendered = toolbar.getDOMElement().innerHTML;
+                expect(toolbarRendered).toContain('TestButtonText!');
+            });
+        });
+
+    });
+
+    function testToolbar(): EditorContainerUiElement {
+        return new EditorContainerUiElement([
+            new EditorOverflowContainer('section-a', 1, []),
+            new EditorOverflowContainer('section-b', 1, []),
+        ]);
+    }
+
+});
\ No newline at end of file
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts
index 5d7632919..f195974d0 100644
--- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts
+++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts
@@ -1069,6 +1069,7 @@ describe('LexicalEditor tests', () => {
           __prev: null,
           __size: 1,
           __style: '',
+          __textFormat: 0,
           __textStyle: '',
           __type: 'paragraph',
         });
@@ -1149,6 +1150,7 @@ describe('LexicalEditor tests', () => {
           __prev: null,
           __size: 1,
           __style: '',
+          __textFormat: 0,
           __textStyle: '',
           __type: 'paragraph',
         });
@@ -2508,8 +2510,8 @@ describe('LexicalEditor tests', () => {
       );
     });
 
-    expect(onError).toBeCalledWith(updateError);
-    expect(textListener).toBeCalledWith('Hello\n\nworld');
+    expect(onError).toHaveBeenCalledWith(updateError);
+    expect(textListener).toHaveBeenCalledWith('Hello\n\nworld');
     expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState);
   });
 
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts
index 97b634503..12fcfbf73 100644
--- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts
+++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts
@@ -76,6 +76,7 @@ describe('LexicalEditorState tests', () => {
         __prev: null,
         __size: 1,
         __style: '',
+        __textFormat: 0,
         __textStyle: '',
         __type: 'paragraph',
       });
@@ -111,7 +112,7 @@ describe('LexicalEditorState tests', () => {
       });
 
       expect(JSON.stringify(editor.getEditorState().toJSON())).toEqual(
-        `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"root","version":1}}`,
+        `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"root","version":1}}`,
       );
     });
 
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts
index fcf666213..33c59bf5f 100644
--- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts
+++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts
@@ -138,7 +138,7 @@ describe('LexicalNode tests', () => {
           const validNode = new TextNode(textNode.__text, textNode.__key);
           expect(textNode.getLatest()).toBe(textNode);
           expect(validNode.getLatest()).toBe(textNode);
-          expect(() => new TestNode(textNode.__key)).toThrowError(
+          expect(() => new TestNode(textNode.__key)).toThrow(
             /TestNode.*re-use key.*TextNode/,
           );
         });
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts
index e08547c13..23b5ffdd5 100644
--- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts
+++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts
@@ -107,7 +107,7 @@ describe('LexicalSerialization tests', () => {
       });
 
       const stringifiedEditorState = JSON.stringify(editor.getEditorState());
-      const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"type":"quote","version":1,"id":"","alignment":"","inset":0},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":4}],"direction":null,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul","id":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}}],"direction":null,"type":"root","version":1}}`;
+      const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"type":"quote","version":1,"id":"","alignment":"","inset":0},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":4}],"direction":null,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul","id":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}}],"direction":null,"type":"root","version":1}}`;
 
       expect(stringifiedEditorState).toBe(expectedStringifiedEditorState);
 
@@ -116,7 +116,7 @@ describe('LexicalSerialization tests', () => {
       const otherStringifiedEditorState = JSON.stringify(editorState);
 
       expect(otherStringifiedEditorState).toBe(
-        `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"type":"quote","version":1,"id":"","alignment":"","inset":0},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":4}],"direction":null,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul","id":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}}],"direction":null,"type":"root","version":1}}`,
+        `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"type":"quote","version":1,"id":"","alignment":"","inset":0},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":4}],"direction":null,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul","id":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}}],"direction":null,"type":"root","version":1}}`,
       );
     });
   });
diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
index 6a8e45724..ab54bdb31 100644
--- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
+++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
@@ -38,6 +38,7 @@ import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 import {EditorUiContext} from "../../../../ui/framework/core";
 import {EditorUIManager} from "../../../../ui/framework/manager";
 import {ImageNode} from "@lexical/rich-text/LexicalImageNode";
+import {MediaNode} from "@lexical/rich-text/LexicalMediaNode";
 
 type TestEnv = {
   readonly container: HTMLDivElement;
@@ -487,6 +488,7 @@ export function createTestContext(): EditorUiContext {
     theme: {},
     nodes: [
         ImageNode,
+        MediaNode,
     ]
   });
 
@@ -502,7 +504,7 @@ export function createTestContext(): EditorUiContext {
     options: {},
     scrollDOM: scrollWrap,
     translate(text: string): string {
-      return "";
+      return text;
     }
   };
 
@@ -767,6 +769,7 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
 
 type nodeTextShape = {
   text: string;
+  format?: number;
 };
 
 type nodeShape = {
@@ -784,7 +787,13 @@ export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextSha
 
   if (shape.type === 'text') {
     // @ts-ignore
-    return  {text: node.text}
+    const shape: nodeTextShape =  {text: node.text}
+    // @ts-ignore
+    if (node && node.format) {
+      // @ts-ignore
+      shape.format = node.format;
+    }
+    return shape;
   }
 
   if (children.length > 0) {
@@ -846,4 +855,42 @@ export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key:
       dispatchKeydownEventForNode(node, editor, key);
     }
   });
+}
+
+export function dispatchEditorMouseClick(editor: LexicalEditor, clientX: number, clientY: number) {
+  const dom = editor.getRootElement();
+  if (!dom) {
+    return;
+  }
+
+  const event = new MouseEvent('click', {
+    clientX: clientX,
+    clientY: clientY,
+    bubbles: true,
+    cancelable: true,
+  });
+  dom?.dispatchEvent(event);
+  editor.commitUpdates();
+}
+
+export function patchRange() {
+    const RangePrototype = Object.getPrototypeOf(document.createRange());
+    RangePrototype.getBoundingClientRect = function (): DOMRect {
+        const rect = {
+            bottom: 0,
+            height: 0,
+            left: 0,
+            right: 0,
+            top: 0,
+            width: 0,
+            x: 0,
+            y: 0,
+        };
+        return {
+            ...rect,
+            toJSON() {
+                return rect;
+            },
+        };
+    };
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts
index 6e3a3861a..0bc7646c9 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts
@@ -18,10 +18,12 @@ import {
 } from 'lexical';
 
 import {
-  $createTestElementNode,
-  createTestEditor,
+    $createTestElementNode,
+    createTestEditor, patchRange,
 } from '../../../__tests__/utils';
 
+patchRange();
+
 describe('LexicalElementNode tests', () => {
   let container: HTMLElement;
 
@@ -54,6 +56,7 @@ describe('LexicalElementNode tests', () => {
 
     editor = createTestEditor();
     editor.setRootElement(root);
+    root.focus();
 
     // Insert initial block
     await update(() => {
@@ -63,11 +66,11 @@ describe('LexicalElementNode tests', () => {
       // Prevent text nodes from combining.
       text2.setMode('segmented');
       const text3 = $createTextNode('Baz');
-      // Some operations require a selection to exist, hence
-      // we make a selection in the setup code.
-      text.select(0, 0);
       block.append(text, text2, text3);
       $getRoot().append(block);
+        // Some operations require a selection to exist, hence
+        // we make a selection in the setup code.
+        text.select(0, 0);
     });
   }
 
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts
index 7bf485ca1..00db14e85 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts
@@ -53,6 +53,7 @@ describe('LexicalParagraphNode tests', () => {
           direction: null,
           id: '',
           inset: 0,
+          textFormat: 0,
           textStyle: '',
           type: 'paragraph',
           version: 1,
diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts
index c54760ff2..0dbf9b94e 100644
--- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts
+++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts
@@ -839,7 +839,7 @@ describe('LexicalTextNode tests', () => {
         paragraph.append(textNode);
 
         const html = $generateHtmlFromNodes($getEditor(), null);
-        expect(html).toBe('<p><u><em><b><code spellcheck="false"><strong>hello</strong></code></b></em></u></p>');
+        expect(html).toBe('<p><u><em><strong><code spellcheck="false"><strong>hello</strong></code></strong></em></u></p>');
       });
     });
 
diff --git a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts
index c03f1bdb2..cee414919 100644
--- a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts
+++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts
@@ -146,12 +146,12 @@ describe('LexicalHeadlessEditor', () => {
       editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, 'foo');
     });
 
-    expect(onUpdate).toBeCalled();
-    expect(onCommand).toBeCalledWith('foo', expect.anything());
-    expect(onTransform).toBeCalledWith(
+    expect(onUpdate).toHaveBeenCalled();
+    expect(onCommand).toHaveBeenCalledWith('foo', expect.anything());
+    expect(onTransform).toHaveBeenCalledWith(
       expect.objectContaining({__type: 'paragraph'}),
     );
-    expect(onTextContent).toBeCalledWith('Helloworld');
+    expect(onTextContent).toHaveBeenCalledWith('Helloworld');
   });
 
   it('can preserve selection for pending editor state (within update loop)', async () => {
diff --git a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts
index e5064121a..b466ee34a 100644
--- a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts
+++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts
@@ -146,7 +146,7 @@ describe('HTML', () => {
     });
 
     expect(html).toBe(
-      '<p>Hello</p><p>World</p>',
+      '<p>Hello</p>\n<p>World</p>',
     );
   });
 
diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts
index 0f3513682..1103f73d3 100644
--- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts
+++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts
@@ -273,18 +273,6 @@ describe('LexicalAutoAutoLinkNode tests', () => {
       });
     });
 
-    test('AutoLinkNode.createDOM() sanitizes javascript: URLs', async () => {
-      const {editor} = testEnv;
-
-      await editor.update(() => {
-        // eslint-disable-next-line no-script-url
-        const autoLinkNode = new AutoLinkNode('javascript:alert(0)');
-        expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe(
-          '<a href="about:blank" class="my-autolink-class"></a>',
-        );
-      });
-    });
-
     test('AutoLinkNode.updateDOM()', async () => {
       const {editor} = testEnv;
 
diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts
index 1aff91863..c50450302 100644
--- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts
+++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts
@@ -218,18 +218,6 @@ describe('LexicalLinkNode tests', () => {
       });
     });
 
-    test('LinkNode.createDOM() sanitizes javascript: URLs', async () => {
-      const {editor} = testEnv;
-
-      await editor.update(() => {
-        // eslint-disable-next-line no-script-url
-        const linkNode = new LinkNode('javascript:alert(0)');
-        expect(linkNode.createDOM(editorConfig).outerHTML).toBe(
-          '<a href="about:blank" class="my-link-class"></a>',
-        );
-      });
-    });
-
     test('LinkNode.updateDOM()', async () => {
       const {editor} = testEnv;
 
diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts
index faa31d887..828aad839 100644
--- a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts
+++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts
@@ -1,6 +1,5 @@
-import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils";
-import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
-import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical";
+import {createTestContext} from "lexical/__tests__/utils";
+import {$createDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 
 const editorConfig = Object.freeze({
     namespace: '',
@@ -9,32 +8,28 @@ const editorConfig = Object.freeze({
 });
 
 describe('LexicalDetailsNode tests', () => {
-    initializeUnitTest((testEnv) => {
+    test('createDOM()', () => {
+        const {editor} = createTestContext();
+        let html!: string;
 
-        test('createDOM()', () => {
-            const {editor} = testEnv;
-            let html!: string;
-
-            editor.updateAndCommit(() => {
-                const details = $createDetailsNode();
-                html = details.createDOM(editorConfig, editor).outerHTML;
-            });
-
-            expect(html).toBe(`<details><summary contenteditable="false"></summary></details>`);
+        editor.updateAndCommit(() => {
+            const details = $createDetailsNode();
+            html = details.createDOM(editorConfig, editor).outerHTML;
         });
 
-        test('exportDOM()', () => {
-            const {editor} = testEnv;
-            let html!: string;
+        expect(html).toBe(`<details contenteditable="false"><summary contenteditable="false"></summary></details>`);
+    });
 
-            editor.updateAndCommit(() => {
-                const details = $createDetailsNode();
-                html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
-            });
+    test('exportDOM()', () => {
+        const {editor} = createTestContext();
+        let html!: string;
 
-            expect(html).toBe(`<details><summary></summary></details>`);
+        editor.updateAndCommit(() => {
+            const details = $createDetailsNode();
+            details.setSummary('Hello there<>!')
+            html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
         });
 
-
+        expect(html).toBe(`<details><summary>Hello there&lt;&gt;!</summary></details>`);
     });
 })
\ No newline at end of file
diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalMediaNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalMediaNode.test.ts
new file mode 100644
index 000000000..b142e95a0
--- /dev/null
+++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalMediaNode.test.ts
@@ -0,0 +1,46 @@
+import {createTestContext} from "lexical/__tests__/utils";
+import {$createMediaNode} from "@lexical/rich-text/LexicalMediaNode";
+
+
+describe('LexicalMediaNode', () => {
+
+    test('setWidth/setHeight/setWidthAndHeight functions remove relevant styles', () => {
+        const {editor} = createTestContext();
+        editor.updateAndCommit(() => {
+            const mediaMode = $createMediaNode('video');
+            const defaultStyles = {style: 'width:20px;height:40px;color:red'};
+
+            mediaMode.setAttributes(defaultStyles);
+            mediaMode.setWidth(60);
+            expect(mediaMode.getWidth()).toBe(60);
+            expect(mediaMode.getAttributes().style).toBe('height:40px;color:red');
+
+            mediaMode.setAttributes(defaultStyles);
+            mediaMode.setHeight(77);
+            expect(mediaMode.getHeight()).toBe(77);
+            expect(mediaMode.getAttributes().style).toBe('width:20px;color:red');
+
+            mediaMode.setAttributes(defaultStyles);
+            mediaMode.setWidthAndHeight('6', '7');
+            expect(mediaMode.getWidth()).toBe(6);
+            expect(mediaMode.getHeight()).toBe(7);
+            expect(mediaMode.getAttributes().style).toBe('color:red');
+        });
+    });
+
+    test('setSrc on video uses sources if existing', () => {
+        const {editor} = createTestContext();
+        editor.updateAndCommit(() => {
+            const mediaMode = $createMediaNode('video');
+            mediaMode.setAttributes({src: 'z'});
+            mediaMode.setSources([{src: 'a', type: 'video'}, {src: 'b', type: 'video'}]);
+
+            mediaMode.setSrc('c');
+
+            expect(mediaMode.getAttributes().src).toBeUndefined();
+            expect(mediaMode.getSources()).toHaveLength(1);
+            expect(mediaMode.getSources()[0].src).toBe('c');
+        });
+    });
+
+});
\ No newline at end of file
diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts
index cc09d1735..14c8b5121 100644
--- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts
+++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts
@@ -37,12 +37,12 @@ import {
   TextNode,
 } from 'lexical';
 import {
-  $assertRangeSelection,
-  $createTestDecoratorNode,
-  $createTestElementNode,
-  createTestEditor,
-  initializeClipboard,
-  invariant,
+    $assertRangeSelection,
+    $createTestDecoratorNode,
+    $createTestElementNode,
+    createTestEditor,
+    initializeClipboard,
+    invariant, patchRange,
 } from 'lexical/__tests__/utils';
 
 import {
@@ -91,24 +91,7 @@ jest.mock('lexical/shared/environment', () => {
   return {...originalModule, IS_FIREFOX: true};
 });
 
-Range.prototype.getBoundingClientRect = function (): DOMRect {
-  const rect = {
-    bottom: 0,
-    height: 0,
-    left: 0,
-    right: 0,
-    top: 0,
-    width: 0,
-    x: 0,
-    y: 0,
-  };
-  return {
-    ...rect,
-    toJSON() {
-      return rect;
-    },
-  };
-};
+patchRange();
 
 describe('LexicalSelection tests', () => {
   let container: HTMLElement;
diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts
index 84c82edec..39d4a04bd 100644
--- a/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts
+++ b/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts
@@ -422,9 +422,6 @@ export function setNativeSelection(
   range.setEnd(focusNode, focusOffset);
   domSelection.removeAllRanges();
   domSelection.addRange(range);
-  Promise.resolve().then(() => {
-    document.dispatchEvent(new Event('selectionchange'));
-  });
 }
 
 export function setNativeSelectionWithPaths(
@@ -647,6 +644,8 @@ export async function applySelectionInputs(
   editor: LexicalEditor,
 ) {
   const rootElement = editor.getRootElement()!;
+  // Set initial focus as if we're in the editor
+  rootElement.focus();
 
   for (let i = 0; i < inputs.length; i++) {
     const input = inputs[i];
diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts
index 2879decda..560d316fe 100644
--- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts
+++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts
@@ -114,7 +114,7 @@ describe('LexicalTableNode tests', () => {
         });
         // Make sure paragraph is inserted inside empty cells
         expect(testEnv.innerHTML).toBe(
-          `<table style="border-collapse: collapse; table-layout: fixed; width: 468pt;"><colgroup><col><col></colgroup><tr style="height: 22.015pt;"><td style="border-left: 1pt solid #000000; border-right: 1pt solid #000000; border-bottom: 1pt solid #000000; border-top: 1pt solid #000000; vertical-align: top; padding: 5pt 5pt 5pt 5pt; overflow: hidden; overflow-wrap: break-word;"><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Hello there</span></p></td><td style="border-left: 1pt solid #000000; border-right: 1pt solid #000000; border-bottom: 1pt solid #000000; border-top: 1pt solid #000000; vertical-align: top; padding: 5pt 5pt 5pt 5pt; overflow: hidden; overflow-wrap: break-word;"><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">General Kenobi!</span></p></td></tr><tr style="height: 22.015pt;"><td style="border-left: 1pt solid #000000; border-right: 1pt solid #000000; border-bottom: 1pt solid #000000; border-top: 1pt solid #000000; vertical-align: top; padding: 5pt 5pt 5pt 5pt; overflow: hidden; overflow-wrap: break-word;"><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Lexical is nice</span></p></td><td style="border-left: 1pt solid #000000; border-right: 1pt solid #000000; border-bottom: 1pt solid #000000; border-top: 1pt solid #000000; vertical-align: top; padding: 5pt 5pt 5pt 5pt; overflow: hidden; overflow-wrap: break-word;"><p><br></p></td></tr></table>`,
+          `<table style="border-collapse: collapse; table-layout: fixed; width: 468pt;"><colgroup><col><col></colgroup><tr style="height: 22.015pt;"><td style="border-left: 1pt solid rgb(0, 0, 0); border-right: 1pt solid rgb(0, 0, 0); border-bottom: 1pt solid rgb(0, 0, 0); border-top: 1pt solid rgb(0, 0, 0); vertical-align: top; padding: 5pt 5pt 5pt 5pt; overflow: hidden; overflow-wrap: break-word;"><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Hello there</span></p></td><td style="border-left: 1pt solid rgb(0, 0, 0); border-right: 1pt solid rgb(0, 0, 0); border-bottom: 1pt solid rgb(0, 0, 0); border-top: 1pt solid rgb(0, 0, 0); vertical-align: top; padding: 5pt 5pt 5pt 5pt; overflow: hidden; overflow-wrap: break-word;"><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">General Kenobi!</span></p></td></tr><tr style="height: 22.015pt;"><td style="border-left: 1pt solid rgb(0, 0, 0); border-right: 1pt solid rgb(0, 0, 0); border-bottom: 1pt solid rgb(0, 0, 0); border-top: 1pt solid rgb(0, 0, 0); vertical-align: top; padding: 5pt 5pt 5pt 5pt; overflow: hidden; overflow-wrap: break-word;"><p><span style="color: rgb(0, 0, 0);" data-lexical-text="true">Lexical is nice</span></p></td><td style="border-left: 1pt solid rgb(0, 0, 0); border-right: 1pt solid rgb(0, 0, 0); border-bottom: 1pt solid rgb(0, 0, 0); border-top: 1pt solid rgb(0, 0, 0); vertical-align: top; padding: 5pt 5pt 5pt 5pt; overflow: hidden; overflow-wrap: break-word;"><p><br></p></td></tr></table>`,
         );
       });
 
diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts
index 1548216cf..b28f2958e 100644
--- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts
+++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts
@@ -123,6 +123,7 @@ describe('table selection', () => {
       __prev: null,
       __size: 1,
       __style: '',
+      __textFormat: 0,
       __textStyle: '',
       __type: 'paragraph',
     });
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts
index 54cd8b54f..6a415d008 100644
--- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts
+++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts
@@ -38,7 +38,7 @@ describe('LexicalUtils#splitNode', () => {
     {
       _: 'split paragraph in between two text nodes',
       expectedHtml:
-        '<p>Hello</p><p>world</p>',
+        '<p>Hello</p>\n<p>world</p>',
       initialHtml: '<p><span>Hello</span><span>world</span></p>',
       splitOffset: 1,
       splitPath: [0],
@@ -46,7 +46,7 @@ describe('LexicalUtils#splitNode', () => {
     {
       _: 'split paragraph before the first text node',
       expectedHtml:
-        '<p><br></p><p>Helloworld</p>',
+        '<p><br></p>\n<p>Helloworld</p>',
       initialHtml: '<p><span>Hello</span><span>world</span></p>',
       splitOffset: 0,
       splitPath: [0],
@@ -54,7 +54,7 @@ describe('LexicalUtils#splitNode', () => {
     {
       _: 'split paragraph after the last text node',
       expectedHtml:
-        '<p>Helloworld</p><p><br></p>',
+        '<p>Helloworld</p>\n<p><br></p>',
       initialHtml: '<p><span>Hello</span><span>world</span></p>',
       splitOffset: 2, // Any offset that is higher than children size
       splitPath: [0],
@@ -62,7 +62,7 @@ describe('LexicalUtils#splitNode', () => {
     {
       _: 'split list items between two text nodes',
       expectedHtml:
-        '<ul><li>Hello</li></ul>' +
+        '<ul><li>Hello</li></ul>\n' +
         '<ul><li>world</li></ul>',
       initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
       splitOffset: 1, // Any offset that is higher than children size
@@ -71,7 +71,7 @@ describe('LexicalUtils#splitNode', () => {
     {
       _: 'split list items before the first text node',
       expectedHtml:
-        '<ul><li></li></ul>' +
+        '<ul><li></li></ul>\n' +
         '<ul><li>Helloworld</li></ul>',
       initialHtml: '<ul><li><span>Hello</span><span>world</span></li></ul>',
       splitOffset: 0, // Any offset that is higher than children size
@@ -83,7 +83,7 @@ describe('LexicalUtils#splitNode', () => {
         '<ul>' +
         '<li>Before</li>' +
         '<li style="list-style: none;"><ul><li>Hello</li></ul></li>' +
-        '</ul>' +
+        '</ul>\n' +
         '<ul>' +
         '<li style="list-style: none;"><ul><li>world</li></ul></li>' +
         '<li>After</li>' +
diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts
index 8c31496de..f13aed408 100644
--- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts
+++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts
@@ -46,7 +46,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
     {
       _: 'insert into paragraph in between two text nodes',
       expectedHtml:
-        '<p>Hello</p><test-decorator></test-decorator><p>world</p>',
+        '<p>Hello</p>\n<test-decorator></test-decorator>\n<p>world</p>',
       initialHtml: '<p><span>Helloworld</span></p>',
       selectionOffset: 5, // Selection on text node after "Hello" world
       selectionPath: [0, 0],
@@ -57,8 +57,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
         '<ul>' +
         '<li>Before</li>' +
         '<li style="list-style: none;"><ul><li>Hello</li></ul></li>' +
-        '</ul>' +
-        '<test-decorator></test-decorator>' +
+        '</ul>\n' +
+        '<test-decorator></test-decorator>\n' +
         '<ul>' +
         '<li style="list-style: none;"><ul><li>world</li></ul></li>' +
         '<li>After</li>' +
@@ -74,7 +74,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
     },
     {
       _: 'insert into empty paragraph',
-      expectedHtml: '<p><br></p><test-decorator></test-decorator><p><br></p>',
+      expectedHtml: '<p><br></p>\n<test-decorator></test-decorator>\n<p><br></p>',
       initialHtml: '<p></p>',
       selectionOffset: 0, // Selection on text node after "Hello" world
       selectionPath: [0],
@@ -82,8 +82,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
     {
       _: 'insert in the end of paragraph',
       expectedHtml:
-        '<p>Hello world</p>' +
-        '<test-decorator></test-decorator>' +
+        '<p>Hello world</p>\n' +
+        '<test-decorator></test-decorator>\n' +
         '<p><br></p>',
       initialHtml: '<p>Hello world</p>',
       selectionOffset: 12, // Selection on text node after "Hello" world
@@ -92,8 +92,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
     {
       _: 'insert in the beginning of paragraph',
       expectedHtml:
-        '<p><br></p>' +
-        '<test-decorator></test-decorator>' +
+        '<p><br></p>\n' +
+        '<test-decorator></test-decorator>\n' +
         '<p>Hello world</p>',
       initialHtml: '<p>Hello world</p>',
       selectionOffset: 0, // Selection on text node after "Hello" world
@@ -102,9 +102,9 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
     {
       _: 'insert with selection on root start',
       expectedHtml:
-        '<test-decorator></test-decorator>' +
-        '<test-decorator></test-decorator>' +
-        '<p>Before</p>' +
+        '<test-decorator></test-decorator>\n' +
+        '<test-decorator></test-decorator>\n' +
+        '<p>Before</p>\n' +
         '<p>After</p>',
       initialHtml:
         '<test-decorator></test-decorator>' +
@@ -116,8 +116,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
     {
       _: 'insert with selection on root child',
       expectedHtml:
-        '<p>Before</p>' +
-        '<test-decorator></test-decorator>' +
+        '<p>Before</p>\n' +
+        '<test-decorator></test-decorator>\n' +
         '<p>After</p>',
       initialHtml: '<p>Before</p><p>After</p>',
       selectionOffset: 1,
@@ -126,7 +126,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => {
     {
       _: 'insert with selection on root end',
       expectedHtml:
-        '<p>Before</p>' +
+        '<p>Before</p>\n' +
         '<test-decorator></test-decorator>',
       initialHtml: '<p>Before</p>',
       selectionOffset: 1,
diff --git a/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts b/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts
new file mode 100644
index 000000000..a3da35206
--- /dev/null
+++ b/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts
@@ -0,0 +1,51 @@
+import {
+    createTestContext, destroyFromContext, dispatchEditorMouseClick,
+} from "lexical/__tests__/utils";
+import {
+    $getRoot, LexicalEditor, LexicalNode,
+    ParagraphNode,
+} from "lexical";
+import {registerRichText} from "@lexical/rich-text";
+import {EditorUiContext} from "../../ui/framework/core";
+import {registerMouseHandling} from "../mouse-handling";
+import {$createTableNode, TableNode} from "@lexical/table";
+
+describe('Mouse-handling service tests', () => {
+
+    let context!: EditorUiContext;
+    let editor!: LexicalEditor;
+
+    beforeEach(() => {
+        context = createTestContext();
+        editor = context.editor;
+        registerRichText(editor);
+        registerMouseHandling(context);
+    });
+
+    afterEach(() => {
+        destroyFromContext(context);
+    });
+
+    test('Click below last table inserts new empty paragraph', () => {
+        let tableNode!: TableNode;
+        let lastRootChild!: LexicalNode|null;
+
+        editor.updateAndCommit(() => {
+            tableNode = $createTableNode();
+            $getRoot().append(tableNode);
+            lastRootChild = $getRoot().getLastChild();
+        });
+
+        expect(lastRootChild).toBeInstanceOf(TableNode);
+
+        const tableDOM = editor.getElementByKey(tableNode.getKey());
+        const rect = tableDOM?.getBoundingClientRect();
+        dispatchEditorMouseClick(editor, 0, (rect?.bottom || 0) + 1)
+
+        editor.getEditorState().read(() => {
+            lastRootChild = $getRoot().getLastChild();
+        });
+
+        expect(lastRootChild).toBeInstanceOf(ParagraphNode);
+    });
+});
\ No newline at end of file
diff --git a/tests/Activity/AuditLogTest.php b/tests/Activity/AuditLogTest.php
index 6b435544d..a6ba6be9f 100644
--- a/tests/Activity/AuditLogTest.php
+++ b/tests/Activity/AuditLogTest.php
@@ -83,6 +83,22 @@ class AuditLogTest extends TestCase
         $resp->assertSeeText("[ID: {$viewer->id}] Deleted User");
     }
 
+    public function test_deleted_user_shows_if_user_created_date_is_later_than_activity()
+    {
+        $viewer = $this->users->viewer();
+        $this->actingAs($viewer);
+        $page = $this->entities->page();
+        $this->activityService->add(ActivityType::PAGE_CREATE, $page);
+        $viewer->created_at = Carbon::now()->addDay();
+        $viewer->save();
+
+        $this->actingAs($this->users->admin());
+
+        $resp = $this->get('settings/audit');
+        $resp->assertSeeText("[ID: {$viewer->id}] Deleted User");
+        $resp->assertDontSee($viewer->name);
+    }
+
     public function test_filters_by_key()
     {
         $this->actingAs($this->users->admin());
diff --git a/tests/Activity/CommentsApiTest.php b/tests/Activity/CommentsApiTest.php
new file mode 100644
index 000000000..51009da39
--- /dev/null
+++ b/tests/Activity/CommentsApiTest.php
@@ -0,0 +1,250 @@
+<?php
+
+namespace Tests\Activity;
+
+use BookStack\Activity\Models\Comment;
+use BookStack\Permissions\Permission;
+use Tests\Api\TestsApi;
+use Tests\TestCase;
+
+class CommentsApiTest extends TestCase
+{
+    use TestsApi;
+
+    public function test_endpoint_permission_controls()
+    {
+        $user = $this->users->editor();
+        $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll, Permission::CommentUpdateAll]);
+
+        $page = $this->entities->page();
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $this->actingAsForApi($user);
+
+        $actions = [
+            ['GET', '/api/comments'],
+            ['GET', "/api/comments/{$comment->id}"],
+            ['POST', "/api/comments"],
+            ['PUT', "/api/comments/{$comment->id}"],
+            ['DELETE', "/api/comments/{$comment->id}"],
+        ];
+
+        foreach ($actions as [$method, $endpoint]) {
+            $resp = $this->call($method, $endpoint);
+            $this->assertNotPermissionError($resp);
+        }
+
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $this->getJson("/api/comments")->assertSee(['id' => $comment->id]);
+
+        $this->permissions->removeUserRolePermissions($user, [
+            Permission::CommentDeleteAll, Permission::CommentDeleteOwn,
+            Permission::CommentUpdateAll, Permission::CommentUpdateOwn,
+            Permission::CommentCreateAll
+        ]);
+
+        $this->assertPermissionError($this->json('delete', "/api/comments/{$comment->id}"));
+        $this->assertPermissionError($this->json('put', "/api/comments/{$comment->id}"));
+        $this->assertPermissionError($this->json('post', "/api/comments"));
+        $this->assertNotPermissionError($this->json('get', "/api/comments/{$comment->id}"));
+
+        $this->permissions->disableEntityInheritedPermissions($page);
+        $this->json('get', "/api/comments/{$comment->id}")->assertStatus(404);
+        $this->getJson("/api/comments")->assertDontSee(['id' => $comment->id]);
+    }
+
+    public function test_index()
+    {
+        $page = $this->entities->page();
+        Comment::query()->delete();
+
+        $comments = Comment::factory()->count(10)->make();
+        $page->comments()->saveMany($comments);
+
+        $firstComment = $comments->first();
+        $resp = $this->actingAsApiEditor()->getJson('/api/comments');
+        $resp->assertJson([
+            'data' => [
+                [
+                    'id' => $firstComment->id,
+                    'commentable_id' => $page->id,
+                    'commentable_type' => 'page',
+                    'parent_id' => null,
+                    'local_id' => $firstComment->local_id,
+                ],
+            ],
+        ]);
+        $resp->assertJsonCount(10, 'data');
+        $resp->assertJson(['total' => 10]);
+
+        $filtered = $this->getJson("/api/comments?filter[id]={$firstComment->id}");
+        $filtered->assertJsonCount(1, 'data');
+        $filtered->assertJson(['total' => 1]);
+    }
+
+    public function test_create()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->actingAsApiEditor()->postJson('/api/comments', [
+            'page_id' => $page->id,
+            'html' => '<p>My wonderful comment</p>',
+            'content_ref' => 'test-content-ref',
+        ]);
+        $resp->assertOk();
+        $id = $resp->json('id');
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $id,
+            'commentable_id' => $page->id,
+            'commentable_type' => 'page',
+            'html' => '<p>My wonderful comment</p>',
+        ]);
+
+        $comment = Comment::query()->findOrFail($id);
+        $this->assertIsInt($comment->local_id);
+
+        $reply = $this->actingAsApiEditor()->postJson('/api/comments', [
+            'page_id' => $page->id,
+            'html' => '<p>My wonderful reply</p>',
+            'content_ref' => 'test-content-ref',
+            'reply_to' => $comment->local_id,
+        ]);
+        $reply->assertOk();
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $reply->json('id'),
+            'commentable_id' => $page->id,
+            'commentable_type' => 'page',
+            'html' => '<p>My wonderful reply</p>',
+            'parent_id' => $comment->local_id,
+        ]);
+    }
+
+    public function test_read()
+    {
+        $page = $this->entities->page();
+        $user = $this->users->viewer();
+        $comment = Comment::factory()->make([
+            'html' => '<p>A lovely comment <script>hello</script></p>',
+            'created_by' => $user->id,
+            'updated_by' => $user->id,
+        ]);
+        $page->comments()->save($comment);
+        $comment->refresh();
+        $reply = Comment::factory()->make([
+            'parent_id' => $comment->local_id,
+            'html' => '<p>A lovely<script>angry</script>reply</p>',
+        ]);
+        $page->comments()->save($reply);
+
+        $resp = $this->actingAsApiEditor()->getJson("/api/comments/{$comment->id}");
+        $resp->assertJson([
+            'id' => $comment->id,
+            'commentable_id' => $page->id,
+            'commentable_type' => 'page',
+            'html' => '<p>A lovely comment </p>',
+            'archived' => false,
+            'created_by' => [
+                'id' => $user->id,
+                'name' => $user->name,
+            ],
+            'updated_by' => [
+                'id' => $user->id,
+                'name' => $user->name,
+            ],
+            'replies' => [
+                [
+                    'id' => $reply->id,
+                    'html' => '<p>A lovelyreply</p>'
+                ]
+            ]
+        ]);
+    }
+
+    public function test_update()
+    {
+        $page = $this->entities->page();
+        $user = $this->users->editor();
+        $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]);
+        $comment = Comment::factory()->make([
+            'html' => '<p>A lovely comment</p>',
+            'created_by' => $this->users->viewer()->id,
+            'updated_by' => $this->users->viewer()->id,
+            'parent_id' => null,
+        ]);
+        $page->comments()->save($comment);
+
+        $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [
+           'html' => '<p>A lovely updated comment</p>',
+        ])->assertOk();
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'html' => '<p>A lovely updated comment</p>',
+            'archived' => 0,
+        ]);
+
+        $this->putJson("/api/comments/{$comment->id}", [
+            'archived' => true,
+        ]);
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'html' => '<p>A lovely updated comment</p>',
+            'archived' => 1,
+        ]);
+
+        $this->putJson("/api/comments/{$comment->id}", [
+            'archived' => false,
+            'html' => '<p>A lovely updated again comment</p>',
+        ]);
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'html' => '<p>A lovely updated again comment</p>',
+            'archived' => 0,
+        ]);
+    }
+
+    public function test_update_cannot_archive_replies()
+    {
+        $page = $this->entities->page();
+        $user = $this->users->editor();
+        $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]);
+        $comment = Comment::factory()->make([
+            'html' => '<p>A lovely comment</p>',
+            'created_by' => $this->users->viewer()->id,
+            'updated_by' => $this->users->viewer()->id,
+            'parent_id' => 90,
+        ]);
+        $page->comments()->save($comment);
+
+        $resp = $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [
+            'archived' => true,
+        ]);
+
+        $this->assertEquals($this->errorResponse('Only top-level comments can be archived.', 400), $resp->json());
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'archived' => 0,
+        ]);
+    }
+
+    public function test_destroy()
+    {
+        $page = $this->entities->page();
+        $user = $this->users->editor();
+        $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll]);
+        $comment = Comment::factory()->make([
+            'html' => '<p>A lovely comment</p>',
+        ]);
+        $page->comments()->save($comment);
+
+        $this->actingAsForApi($user)->deleteJson("/api/comments/{$comment->id}")->assertStatus(204);
+        $this->assertDatabaseMissing('comments', [
+            'id' => $comment->id,
+        ]);
+    }
+}
diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php
index c405b07ae..8be09f890 100644
--- a/tests/Activity/WatchTest.php
+++ b/tests/Activity/WatchTest.php
@@ -327,6 +327,24 @@ class WatchTest extends TestCase
         });
     }
 
+    public function test_notify_watch_page_ignore_when_no_page_owner()
+    {
+        $editor = $this->users->editor();
+        $entities = $this->entities->createChainBelongingToUser($editor);
+        $entities['page']->owned_by = null;
+        $entities['page']->save();
+
+        $watches = new UserEntityWatchOptions($editor, $entities['page']);
+        $watches->updateLevelByValue(WatchLevels::IGNORE);
+
+        $notifications = Notification::fake();
+        $this->asAdmin();
+
+        $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']);
+
+        $notifications->assertNothingSent();
+    }
+
     public function test_notifications_sent_in_right_language()
     {
         $editor = $this->users->editor();
@@ -340,8 +358,8 @@ class WatchTest extends TestCase
             ActivityType::PAGE_CREATE => $entities['page'],
             ActivityType::PAGE_UPDATE => $entities['page'],
             ActivityType::COMMENT_CREATE => Comment::factory()->make([
-                'entity_id' => $entities['page']->id,
-                'entity_type' => $entities['page']->getMorphClass(),
+                'commentable_id' => $entities['page']->id,
+                'commentable_type' => $entities['page']->getMorphClass(),
             ]),
         ];
 
diff --git a/tests/Api/ApiAuthTest.php b/tests/Api/ApiAuthTest.php
index 93e4b02e4..4e446bf5d 100644
--- a/tests/Api/ApiAuthTest.php
+++ b/tests/Api/ApiAuthTest.php
@@ -12,7 +12,7 @@ class ApiAuthTest extends TestCase
 {
     use TestsApi;
 
-    protected $endpoint = '/api/books';
+    protected string $endpoint = '/api/books';
 
     public function test_requests_succeed_with_default_auth()
     {
diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php
index 084cb59bd..86e10f58a 100644
--- a/tests/Api/BooksApiTest.php
+++ b/tests/Api/BooksApiTest.php
@@ -5,7 +5,6 @@ namespace Tests\Api;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Repos\BaseRepo;
 use Carbon\Carbon;
-use Illuminate\Support\Facades\DB;
 use Tests\TestCase;
 
 class BooksApiTest extends TestCase
@@ -47,8 +46,8 @@ class BooksApiTest extends TestCase
             [
                 'id'   => $book->id,
                 'cover' => [
-                    'id' => $book->cover->id,
-                    'url' => $book->cover->url,
+                    'id' => $book->coverInfo()->getImage()->id,
+                    'url' => $book->coverInfo()->getImage()->url,
                 ],
             ],
         ]]);
@@ -94,7 +93,7 @@ class BooksApiTest extends TestCase
         ]);
 
         $resp->assertJson($expectedDetails);
-        $this->assertDatabaseHas('books', $expectedDetails);
+        $this->assertDatabaseHasEntityData('book', $expectedDetails);
     }
 
     public function test_book_name_needed_to_create()
@@ -153,23 +152,23 @@ class BooksApiTest extends TestCase
         $directChildCount = $book->directPages()->count() + $book->chapters()->count();
         $resp->assertStatus(200);
         $resp->assertJsonCount($directChildCount, 'contents');
-        $resp->assertJson([
-            'contents' => [
-                [
-                    'type' => 'chapter',
-                    'id' => $chapter->id,
-                    'name' => $chapter->name,
-                    'slug' => $chapter->slug,
-                    'pages' => [
-                        [
-                            'id' => $chapterPage->id,
-                            'name' => $chapterPage->name,
-                            'slug' => $chapterPage->slug,
-                        ]
-                    ]
-                ]
-            ]
-        ]);
+
+        $contents = $resp->json('contents');
+        $respChapter = array_values(array_filter($contents, fn ($item) =>  ($item['id'] === $chapter->id && $item['type'] === 'chapter')))[0];
+        $this->assertArrayMapIncludes([
+            'id' => $chapter->id,
+            'type' => 'chapter',
+            'name' => $chapter->name,
+            'slug' => $chapter->slug,
+        ], $respChapter);
+
+        $respPage = array_values(array_filter($respChapter['pages'], fn ($item) =>  ($item['id'] === $chapterPage->id)))[0];
+
+        $this->assertArrayMapIncludes([
+            'id' => $chapterPage->id,
+            'name' => $chapterPage->name,
+            'slug' => $chapterPage->slug,
+        ], $respPage);
     }
 
     public function test_read_endpoint_contents_nested_pages_has_permissions_applied()
@@ -224,14 +223,14 @@ class BooksApiTest extends TestCase
         $resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details);
         $resp->assertStatus(200);
 
-        $this->assertDatabaseHas('books', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API']));
+        $this->assertDatabaseHasEntityData('book', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API']));
     }
 
     public function test_update_increments_updated_date_if_only_tags_are_sent()
     {
         $this->actingAsApiEditor();
         $book = $this->entities->book();
-        DB::table('books')->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]);
+        Book::query()->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]);
 
         $details = [
             'tags' => [['name' => 'Category', 'value' => 'Testing']],
@@ -247,7 +246,7 @@ class BooksApiTest extends TestCase
         $this->actingAsApiEditor();
         /** @var Book $book */
         $book = $this->entities->book();
-        $this->assertNull($book->cover);
+        $this->assertNull($book->coverInfo()->getImage());
         $file = $this->files->uploadedImage('image.png');
 
         // Ensure cover image can be set via API
@@ -257,7 +256,7 @@ class BooksApiTest extends TestCase
         $book->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNotNull($book->cover);
+        $this->assertNotNull($book->coverInfo()->getImage());
 
         // Ensure further updates without image do not clear cover image
         $resp = $this->put($this->baseEndpoint . "/{$book->id}", [
@@ -266,7 +265,7 @@ class BooksApiTest extends TestCase
         $book->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNotNull($book->cover);
+        $this->assertNotNull($book->coverInfo()->getImage());
 
         // Ensure update with null image property clears image
         $resp = $this->put($this->baseEndpoint . "/{$book->id}", [
@@ -275,7 +274,7 @@ class BooksApiTest extends TestCase
         $book->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNull($book->cover);
+        $this->assertNull($book->coverInfo()->getImage());
     }
 
     public function test_delete_endpoint()
@@ -287,62 +286,4 @@ class BooksApiTest extends TestCase
         $resp->assertStatus(204);
         $this->assertActivityExists('book_delete');
     }
-
-    public function test_export_html_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $book = $this->entities->book();
-
-        $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/html");
-        $resp->assertStatus(200);
-        $resp->assertSee($book->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
-    }
-
-    public function test_export_plain_text_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $book = $this->entities->book();
-
-        $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/plaintext");
-        $resp->assertStatus(200);
-        $resp->assertSee($book->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
-    }
-
-    public function test_export_pdf_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $book = $this->entities->book();
-
-        $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/pdf");
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
-    }
-
-    public function test_export_markdown_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $book = Book::visible()->has('pages')->has('chapters')->first();
-
-        $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/markdown");
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"');
-        $resp->assertSee('# ' . $book->name);
-        $resp->assertSee('# ' . $book->pages()->first()->name);
-        $resp->assertSee('# ' . $book->chapters()->first()->name);
-    }
-
-    public function test_cant_export_when_not_have_permission()
-    {
-        $types = ['html', 'plaintext', 'pdf', 'markdown'];
-        $this->actingAsApiEditor();
-        $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']);
-
-        $book = $this->entities->book();
-        foreach ($types as $type) {
-            $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/{$type}");
-            $this->assertPermissionError($resp);
-        }
-    }
 }
diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php
index 9698d4dd9..194140a56 100644
--- a/tests/Api/ChaptersApiTest.php
+++ b/tests/Api/ChaptersApiTest.php
@@ -91,7 +91,7 @@ class ChaptersApiTest extends TestCase
             'description' => 'A chapter created via the API',
         ]);
         $resp->assertJson($expectedDetails);
-        $this->assertDatabaseHas('chapters', $expectedDetails);
+        $this->assertDatabaseHasEntityData('chapter', $expectedDetails);
     }
 
     public function test_chapter_name_needed_to_create()
@@ -155,7 +155,7 @@ class ChaptersApiTest extends TestCase
                     'owned_by' => $page->owned_by,
                     'created_by' => $page->created_by,
                     'updated_by' => $page->updated_by,
-                    'book_id' => $page->id,
+                    'book_id' => $page->book->id,
                     'chapter_id' => $chapter->id,
                     'priority' => $page->priority,
                     'book_slug' => $chapter->book->slug,
@@ -213,7 +213,7 @@ class ChaptersApiTest extends TestCase
         $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details);
         $resp->assertStatus(200);
 
-        $this->assertDatabaseHas('chapters', array_merge($details, [
+        $this->assertDatabaseHasEntityData('chapter', array_merge($details, [
             'id' => $chapter->id, 'description' => 'A chapter updated via the API'
         ]));
     }
@@ -222,7 +222,7 @@ class ChaptersApiTest extends TestCase
     {
         $this->actingAsApiEditor();
         $chapter = $this->entities->chapter();
-        DB::table('chapters')->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]);
+        $chapter->newQuery()->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]);
 
         $details = [
             'tags' => [['name' => 'Category', 'value' => 'Testing']],
@@ -244,8 +244,8 @@ class ChaptersApiTest extends TestCase
         $resp->assertOk();
         $chapter->refresh();
 
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'book_id' => $newBook->id]);
-        $this->assertDatabaseHas('pages', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'book_id' => $newBook->id]);
+        $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]);
     }
 
     public function test_update_with_new_book_id_requires_delete_permission()
@@ -269,61 +269,4 @@ class ChaptersApiTest extends TestCase
         $resp->assertStatus(204);
         $this->assertActivityExists('chapter_delete');
     }
-
-    public function test_export_html_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $chapter = $this->entities->chapter();
-
-        $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/html");
-        $resp->assertStatus(200);
-        $resp->assertSee($chapter->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
-    }
-
-    public function test_export_plain_text_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $chapter = $this->entities->chapter();
-
-        $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/plaintext");
-        $resp->assertStatus(200);
-        $resp->assertSee($chapter->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
-    }
-
-    public function test_export_pdf_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $chapter = $this->entities->chapter();
-
-        $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/pdf");
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
-    }
-
-    public function test_export_markdown_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $chapter = Chapter::visible()->has('pages')->first();
-
-        $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/markdown");
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"');
-        $resp->assertSee('# ' . $chapter->name);
-        $resp->assertSee('# ' . $chapter->pages()->first()->name);
-    }
-
-    public function test_cant_export_when_not_have_permission()
-    {
-        $types = ['html', 'plaintext', 'pdf', 'markdown'];
-        $this->actingAsApiEditor();
-        $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']);
-
-        $chapter = Chapter::visible()->has('pages')->first();
-        foreach ($types as $type) {
-            $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/{$type}");
-            $this->assertPermissionError($resp);
-        }
-    }
 }
diff --git a/tests/Api/ContentPermissionsApiTest.php b/tests/Api/ContentPermissionsApiTest.php
index a62abacc7..464d62683 100644
--- a/tests/Api/ContentPermissionsApiTest.php
+++ b/tests/Api/ContentPermissionsApiTest.php
@@ -280,7 +280,7 @@ class ContentPermissionsApiTest extends TestCase
         ]);
 
         $resp->assertOk();
-        $this->assertDatabaseHas('pages', ['id' => $page->id, 'owned_by' => $user->id]);
+        $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'owned_by' => $user->id]);
         $this->assertDatabaseHas('entity_permissions', [
             'entity_id' => $page->id,
             'entity_type' => 'page',
diff --git a/tests/Api/ExportsApiTest.php b/tests/Api/ExportsApiTest.php
new file mode 100644
index 000000000..e1ac698d0
--- /dev/null
+++ b/tests/Api/ExportsApiTest.php
@@ -0,0 +1,209 @@
+<?php
+
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use Tests\Exports\ZipTestHelper;
+use Tests\TestCase;
+
+class ExportsApiTest extends TestCase
+{
+    use TestsApi;
+
+    public function test_book_html_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $book = $this->entities->book();
+
+        $resp = $this->get("/api/books/{$book->id}/export/html");
+        $resp->assertStatus(200);
+        $resp->assertSee($book->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
+    }
+
+    public function test_book_plain_text_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $book = $this->entities->book();
+
+        $resp = $this->get("/api/books/{$book->id}/export/plaintext");
+        $resp->assertStatus(200);
+        $resp->assertSee($book->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
+    }
+
+    public function test_book_pdf_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $book = $this->entities->book();
+
+        $resp = $this->get("/api/books/{$book->id}/export/pdf");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
+    }
+
+    public function test_book_markdown_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $book = Book::visible()->has('pages')->has('chapters')->first();
+
+        $resp = $this->get("/api/books/{$book->id}/export/markdown");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"');
+        $resp->assertSee('# ' . $book->name);
+        $resp->assertSee('# ' . $book->pages()->first()->name);
+        $resp->assertSee('# ' . $book->chapters()->first()->name);
+    }
+
+    public function test_book_zip_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $book = Book::visible()->has('pages')->has('chapters')->first();
+
+        $resp = $this->get("/api/books/{$book->id}/export/zip");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.zip"');
+
+        $zip = ZipTestHelper::extractFromZipResponse($resp);
+        $this->assertArrayHasKey('book', $zip->data);
+    }
+
+    public function test_chapter_html_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $chapter = $this->entities->chapter();
+
+        $resp = $this->get("/api/chapters/{$chapter->id}/export/html");
+        $resp->assertStatus(200);
+        $resp->assertSee($chapter->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
+    }
+
+    public function test_chapter_plain_text_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $chapter = $this->entities->chapter();
+
+        $resp = $this->get("/api/chapters/{$chapter->id}/export/plaintext");
+        $resp->assertStatus(200);
+        $resp->assertSee($chapter->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
+    }
+
+    public function test_chapter_pdf_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $chapter = $this->entities->chapter();
+
+        $resp = $this->get("/api/chapters/{$chapter->id}/export/pdf");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
+    }
+
+    public function test_chapter_markdown_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $chapter = Chapter::visible()->has('pages')->first();
+
+        $resp = $this->get("/api/chapters/{$chapter->id}/export/markdown");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"');
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $chapter->pages()->first()->name);
+    }
+
+    public function test_chapter_zip_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $chapter = Chapter::visible()->has('pages')->first();
+
+        $resp = $this->get("/api/chapters/{$chapter->id}/export/zip");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.zip"');
+
+        $zip = ZipTestHelper::extractFromZipResponse($resp);
+        $this->assertArrayHasKey('chapter', $zip->data);
+    }
+
+    public function test_page_html_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = $this->entities->page();
+
+        $resp = $this->get("/api/pages/{$page->id}/export/html");
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
+    }
+
+    public function test_page_plain_text_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = $this->entities->page();
+
+        $resp = $this->get("/api/pages/{$page->id}/export/plaintext");
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
+    }
+
+    public function test_page_pdf_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = $this->entities->page();
+
+        $resp = $this->get("/api/pages/{$page->id}/export/pdf");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
+    }
+
+    public function test_page_markdown_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = $this->entities->page();
+
+        $resp = $this->get("/api/pages/{$page->id}/export/markdown");
+        $resp->assertStatus(200);
+        $resp->assertSee('# ' . $page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
+    }
+
+    public function test_page_zip_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = $this->entities->page();
+
+        $resp = $this->get("/api/pages/{$page->id}/export/zip");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.zip"');
+
+        $zip = ZipTestHelper::extractFromZipResponse($resp);
+        $this->assertArrayHasKey('page', $zip->data);
+    }
+
+    public function test_cant_export_when_not_have_permission()
+    {
+        $types = ['html', 'plaintext', 'pdf', 'markdown', 'zip'];
+        $this->actingAsApiEditor();
+        $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']);
+
+        $book = $this->entities->book();
+        foreach ($types as $type) {
+            $resp = $this->get("/api/books/{$book->id}/export/{$type}");
+            $this->assertPermissionError($resp);
+        }
+
+        $chapter = Chapter::visible()->has('pages')->first();
+        foreach ($types as $type) {
+            $resp = $this->get("/api/chapters/{$chapter->id}/export/{$type}");
+            $this->assertPermissionError($resp);
+        }
+
+        $page = $this->entities->page();
+        foreach ($types as $type) {
+            $resp = $this->get("/api/pages/{$page->id}/export/{$type}");
+            $this->assertPermissionError($resp);
+        }
+    }
+}
diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php
index 667093107..07c20c834 100644
--- a/tests/Api/ImageGalleryApiTest.php
+++ b/tests/Api/ImageGalleryApiTest.php
@@ -275,6 +275,69 @@ class ImageGalleryApiTest extends TestCase
         $resp->assertStatus(404);
     }
 
+    public function test_read_data_endpoint()
+    {
+        $this->actingAsApiAdmin();
+        $imagePage = $this->entities->page();
+        $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png');
+        $image = Image::findOrFail($data['response']->id);
+
+        $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Type', 'image/png');
+
+        $respData = $resp->streamedContent();
+        $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData);
+    }
+
+    public function test_read_data_endpoint_permission_controlled()
+    {
+        $this->actingAsApiEditor();
+        $imagePage = $this->entities->page();
+        $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png');
+        $image = Image::findOrFail($data['response']->id);
+
+        $this->get("{$this->baseEndpoint}/{$image->id}/data")->assertOk();
+
+        $this->permissions->disableEntityInheritedPermissions($imagePage);
+
+        $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data");
+        $resp->assertStatus(404);
+    }
+
+    public function test_read_url_data_endpoint()
+    {
+        $this->actingAsApiAdmin();
+        $imagePage = $this->entities->page();
+        $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png');
+
+        $url = url($data['response']->path);
+        $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url));
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Type', 'image/png');
+
+        $respData = $resp->streamedContent();
+        $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData);
+    }
+
+    public function test_read_url_data_endpoint_permission_controlled_when_local_secure_restricted_storage_is_used()
+    {
+        config()->set('filesystems.images', 'local_secure_restricted');
+
+        $this->actingAsApiEditor();
+        $imagePage = $this->entities->page();
+        $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png');
+
+        $url = url($data['response']->path);
+        $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url));
+        $resp->assertStatus(200);
+
+        $this->permissions->disableEntityInheritedPermissions($imagePage);
+
+        $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url));
+        $resp->assertStatus(404);
+    }
+
     public function test_update_endpoint()
     {
         $this->actingAsApiAdmin();
diff --git a/tests/Api/ImportsApiTest.php b/tests/Api/ImportsApiTest.php
new file mode 100644
index 000000000..7026868c9
--- /dev/null
+++ b/tests/Api/ImportsApiTest.php
@@ -0,0 +1,175 @@
+<?php
+
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Exports\Import;
+use Tests\Exports\ZipTestHelper;
+use Tests\TestCase;
+
+class ImportsApiTest extends TestCase
+{
+    use TestsApi;
+
+    protected string $baseEndpoint = '/api/imports';
+
+    public function test_create_and_run(): void
+    {
+        $book = $this->entities->book();
+        $zip = ZipTestHelper::zipUploadFromData([
+            'page' => [
+                'name' => 'My API import page',
+                'tags' => [
+                    [
+                        'name' => 'My api tag',
+                        'value' => 'api test value'
+                    ]
+                ],
+            ],
+        ]);
+
+        $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]);
+        $resp->assertStatus(200);
+
+        $importId = $resp->json('id');
+        $import = Import::query()->findOrFail($importId);
+        $this->assertEquals('page', $import->type);
+
+        $resp = $this->post($this->baseEndpoint . "/{$import->id}", [
+            'parent_type' => 'book',
+            'parent_id' => $book->id,
+        ]);
+        $resp->assertJson([
+            'name' => 'My API import page',
+            'book_id' => $book->id,
+        ]);
+        $resp->assertJsonMissingPath('book');
+
+        $page = Page::query()->where('name', '=', 'My API import page')->first();
+        $this->assertEquals('My api tag', $page->tags()->first()->name);
+    }
+
+    public function test_create_validation_error(): void
+    {
+        $zip = ZipTestHelper::zipUploadFromData([
+            'page' => [
+                'tags' => [
+                    [
+                        'name' => 'My api tag',
+                        'value' => 'api test value'
+                    ]
+                ],
+            ],
+        ]);
+
+        $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]);
+        $resp->assertStatus(422);
+        $message = $resp->json('message');
+
+        $this->assertStringContainsString('ZIP upload failed with the following validation errors:', $message);
+        $this->assertStringContainsString('[page.name] The name field is required.', $message);
+    }
+
+    public function test_list(): void
+    {
+        $imports = Import::factory()->count(10)->create();
+
+        $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint);
+        $resp->assertJsonCount(10, 'data');
+        $resp->assertJsonPath('total', 10);
+
+        $firstImport = $imports->first();
+        $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint . '?filter[id]=' . $firstImport->id);
+        $resp->assertJsonCount(1, 'data');
+        $resp->assertJsonPath('data.0.id', $firstImport->id);
+        $resp->assertJsonPath('data.0.name', $firstImport->name);
+        $resp->assertJsonPath('data.0.size', $firstImport->size);
+        $resp->assertJsonPath('data.0.type', $firstImport->type);
+    }
+
+    public function test_list_visibility_limited(): void
+    {
+        $user = $this->users->editor();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $resp = $this->actingAsForApi($user)->get($this->baseEndpoint);
+        $resp->assertJsonCount(1, 'data');
+        $resp->assertJsonPath('data.0.name', 'MySuperUserImport');
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $resp = $this->actingAsForApi($user)->get($this->baseEndpoint);
+        $resp->assertJsonCount(2, 'data');
+        $resp->assertJsonPath('data.1.name', 'MySuperAdminImport');
+    }
+
+    public function test_read(): void
+    {
+        $zip = ZipTestHelper::zipUploadFromData([
+            'book' => [
+                'name' => 'My API import book',
+                'pages' => [
+                    [
+                        'name' => 'My import page',
+                        'tags' => [
+                            [
+                                'name' => 'My api tag',
+                                'value' => 'api test value'
+                            ]
+                        ]
+                    ]
+                ],
+            ],
+        ]);
+
+        $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]);
+        $resp->assertStatus(200);
+
+        $resp = $this->get($this->baseEndpoint . "/{$resp->json('id')}");
+        $resp->assertStatus(200);
+
+        $resp->assertJsonPath('details.name', 'My API import book');
+        $resp->assertJsonPath('details.pages.0.name', 'My import page');
+        $resp->assertJsonPath('details.pages.0.tags.0.name', 'My api tag');
+        $resp->assertJsonMissingPath('metadata');
+    }
+
+    public function test_delete(): void
+    {
+        $import = Import::factory()->create();
+
+        $resp = $this->actingAsApiAdmin()->delete($this->baseEndpoint . "/{$import->id}");
+        $resp->assertStatus(204);
+    }
+
+    public function test_content_import_permissions_needed(): void
+    {
+        $user = $this->users->viewer();
+        $this->permissions->grantUserRolePermissions($user, ['access-api']);
+        $this->actingAsForApi($user);
+        $requests = [
+             ['GET', $this->baseEndpoint],
+             ['POST', $this->baseEndpoint],
+             ['GET', $this->baseEndpoint . "/1"],
+             ['POST', $this->baseEndpoint . "/1"],
+             ['DELETE', $this->baseEndpoint . "/1"],
+        ];
+
+        foreach ($requests as $request) {
+            [$method, $endpoint] = $request;
+            $resp = $this->json($method, $endpoint);
+            $resp->assertStatus(403);
+        }
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        foreach ($requests as $request) {
+            [$method, $endpoint] = $request;
+            $resp = $this->call($method, $endpoint);
+            $this->assertNotEquals(403, $resp->status(), "A {$method} request to {$endpoint} returned 403");
+        }
+    }
+}
diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php
index 22659d5bb..d71b6c988 100644
--- a/tests/Api/PagesApiTest.php
+++ b/tests/Api/PagesApiTest.php
@@ -2,6 +2,7 @@
 
 namespace Tests\Api;
 
+use BookStack\Activity\Models\Comment;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use Carbon\Carbon;
@@ -199,6 +200,31 @@ class PagesApiTest extends TestCase
         $this->assertSame(404, $resp->json('error')['code']);
     }
 
+    public function test_read_endpoint_includes_page_comments_tree_structure()
+    {
+        $this->actingAsApiEditor();
+        $page = $this->entities->page();
+        $relation = ['commentable_type' => 'page', 'commentable_id' => $page->id];
+        $active = Comment::factory()->create([...$relation, 'html' => '<p>My active<script>cat</script> comment</p>']);
+        Comment::factory()->count(5)->create([...$relation, 'parent_id' => $active->local_id]);
+        $archived = Comment::factory()->create([...$relation, 'archived' => true]);
+        Comment::factory()->count(2)->create([...$relation, 'parent_id' => $archived->local_id]);
+
+        $resp = $this->getJson("{$this->baseEndpoint}/{$page->id}");
+        $resp->assertOk();
+
+        $resp->assertJsonCount(1, 'comments.active');
+        $resp->assertJsonCount(1, 'comments.archived');
+        $resp->assertJsonCount(5, 'comments.active.0.children');
+        $resp->assertJsonCount(2, 'comments.archived.0.children');
+
+        $resp->assertJsonFragment([
+            'id' => $active->id,
+            'local_id' => $active->local_id,
+            'html' => '<p>My active comment</p>',
+        ]);
+    }
+
     public function test_update_endpoint()
     {
         $this->actingAsApiEditor();
@@ -286,7 +312,7 @@ class PagesApiTest extends TestCase
     {
         $this->actingAsApiEditor();
         $page = $this->entities->page();
-        DB::table('pages')->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]);
+        $page->newQuery()->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]);
 
         $details = [
             'tags' => [['name' => 'Category', 'value' => 'Testing']],
@@ -308,60 +334,4 @@ class PagesApiTest extends TestCase
         $resp->assertStatus(204);
         $this->assertActivityExists('page_delete', $page);
     }
-
-    public function test_export_html_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $page = $this->entities->page();
-
-        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/html");
-        $resp->assertStatus(200);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
-    }
-
-    public function test_export_plain_text_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $page = $this->entities->page();
-
-        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/plaintext");
-        $resp->assertStatus(200);
-        $resp->assertSee($page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
-    }
-
-    public function test_export_pdf_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $page = $this->entities->page();
-
-        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/pdf");
-        $resp->assertStatus(200);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
-    }
-
-    public function test_export_markdown_endpoint()
-    {
-        $this->actingAsApiEditor();
-        $page = $this->entities->page();
-
-        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/markdown");
-        $resp->assertStatus(200);
-        $resp->assertSee('# ' . $page->name);
-        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
-    }
-
-    public function test_cant_export_when_not_have_permission()
-    {
-        $types = ['html', 'plaintext', 'pdf', 'markdown'];
-        $this->actingAsApiEditor();
-        $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']);
-
-        $page = $this->entities->page();
-        foreach ($types as $type) {
-            $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/{$type}");
-            $this->assertPermissionError($resp);
-        }
-    }
 }
diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php
index d174838c2..6ccc69c35 100644
--- a/tests/Api/RecycleBinApiTest.php
+++ b/tests/Api/RecycleBinApiTest.php
@@ -144,7 +144,7 @@ class RecycleBinApiTest extends TestCase
 
         $deletion = Deletion::query()->orderBy('id')->first();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'            => $page->id,
             'deleted_at'    => $page->deleted_at,
         ]);
@@ -154,7 +154,7 @@ class RecycleBinApiTest extends TestCase
             'restore_count' => 1,
         ]);
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'            => $page->id,
             'deleted_at'    => null,
         ]);
@@ -168,7 +168,7 @@ class RecycleBinApiTest extends TestCase
 
         $deletion = Deletion::query()->orderBy('id')->first();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'            => $page->id,
             'deleted_at'    => $page->deleted_at,
         ]);
@@ -178,6 +178,6 @@ class RecycleBinApiTest extends TestCase
             'delete_count' => 1,
         ]);
 
-        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
     }
 }
diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php
index 9da7900ca..517c5d8e4 100644
--- a/tests/Api/SearchApiTest.php
+++ b/tests/Api/SearchApiTest.php
@@ -113,6 +113,7 @@ class SearchApiTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($book);
 
         $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue');
+        $resp->assertOk();
         $resp->assertJsonPath('data.0.id', $page->id);
         $resp->assertJsonMissingPath('data.0.book.name');
     }
diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php
index ba13c0153..34ce0e4e5 100644
--- a/tests/Api/ShelvesApiTest.php
+++ b/tests/Api/ShelvesApiTest.php
@@ -48,8 +48,8 @@ class ShelvesApiTest extends TestCase
             [
                 'id'   => $shelf->id,
                 'cover' => [
-                    'id' => $shelf->cover->id,
-                    'url' => $shelf->cover->url,
+                    'id' => $shelf->coverInfo()->getImage()->id,
+                    'url' => $shelf->coverInfo()->getImage()->url,
                 ],
             ],
         ]]);
@@ -102,7 +102,7 @@ class ShelvesApiTest extends TestCase
         ]);
 
         $resp->assertJson($expectedDetails);
-        $this->assertDatabaseHas('bookshelves', $expectedDetails);
+        $this->assertDatabaseHasEntityData('bookshelf', $expectedDetails);
     }
 
     public function test_shelf_name_needed_to_create()
@@ -181,14 +181,14 @@ class ShelvesApiTest extends TestCase
         $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details);
         $resp->assertStatus(200);
 
-        $this->assertDatabaseHas('bookshelves', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API']));
+        $this->assertDatabaseHasEntityData('bookshelf', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API']));
     }
 
     public function test_update_increments_updated_date_if_only_tags_are_sent()
     {
         $this->actingAsApiEditor();
         $shelf = Bookshelf::visible()->first();
-        DB::table('bookshelves')->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]);
+        $shelf->newQuery()->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]);
 
         $details = [
             'tags' => [['name' => 'Category', 'value' => 'Testing']],
@@ -222,7 +222,7 @@ class ShelvesApiTest extends TestCase
         $this->actingAsApiEditor();
         /** @var Book $shelf */
         $shelf = Bookshelf::visible()->first();
-        $this->assertNull($shelf->cover);
+        $this->assertNull($shelf->coverInfo()->getImage());
         $file = $this->files->uploadedImage('image.png');
 
         // Ensure cover image can be set via API
@@ -232,7 +232,7 @@ class ShelvesApiTest extends TestCase
         $shelf->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNotNull($shelf->cover);
+        $this->assertNotNull($shelf->coverInfo()->getImage());
 
         // Ensure further updates without image do not clear cover image
         $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [
@@ -241,7 +241,7 @@ class ShelvesApiTest extends TestCase
         $shelf->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNotNull($shelf->cover);
+        $this->assertNotNull($shelf->coverInfo()->getImage());
 
         // Ensure update with null image property clears image
         $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [
@@ -250,7 +250,7 @@ class ShelvesApiTest extends TestCase
         $shelf->refresh();
 
         $resp->assertStatus(200);
-        $this->assertNull($shelf->cover);
+        $this->assertNull($shelf->coverInfo()->getImage());
     }
 
     public function test_delete_endpoint()
diff --git a/tests/Api/SystemApiTest.php b/tests/Api/SystemApiTest.php
new file mode 100644
index 000000000..e915286e8
--- /dev/null
+++ b/tests/Api/SystemApiTest.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Tests\Api;
+
+use Tests\TestCase;
+
+class SystemApiTest extends TestCase
+{
+    use TestsApi;
+
+    public function test_read_returns_app_info(): void
+    {
+        $resp = $this->actingAsApiEditor()->get('/api/system');
+        $data = $resp->json();
+
+        $this->assertStringStartsWith('v', $data['version']);
+        $this->assertEquals(setting('instance-id'), $data['instance_id']);
+        $this->assertEquals(setting('app-name'), $data['app_name']);
+        $this->assertEquals(url('/logo.png'), $data['app_logo']);
+        $this->assertEquals(url('/'), $data['base_url']);
+    }
+}
diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php
index 1f359b41a..5184bf984 100644
--- a/tests/Auth/MfaConfigurationTest.php
+++ b/tests/Auth/MfaConfigurationTest.php
@@ -6,6 +6,7 @@ use BookStack\Access\Mfa\MfaValue;
 use BookStack\Activity\ActivityType;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
+use Illuminate\Support\Facades\Hash;
 use PragmaRX\Google2FA\Google2FA;
 use Tests\TestCase;
 
@@ -166,6 +167,36 @@ class MfaConfigurationTest extends TestCase
         $this->assertEquals(0, $admin->mfaValues()->count());
     }
 
+    public function test_mfa_required_if_set_on_role()
+    {
+        $user = $this->users->viewer();
+        $user->password = Hash::make('password');
+        $user->save();
+        /** @var Role $role */
+        $role = $user->roles()->first();
+        $role->mfa_enforced = true;
+        $role->save();
+
+        $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']);
+        $this->assertFalse(auth()->check());
+        $resp->assertRedirect('/mfa/verify');
+    }
+
+    public function test_mfa_required_if_mfa_option_configured()
+    {
+        $user = $this->users->viewer();
+        $user->password = Hash::make('password');
+        $user->save();
+        $user->mfaValues()->create([
+            'method' => MfaValue::METHOD_TOTP,
+            'value'  => 'test',
+        ]);
+
+        $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']);
+        $this->assertFalse(auth()->check());
+        $resp->assertRedirect('/mfa/verify');
+    }
+
     public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login()
     {
         $admin = $this->users->admin();
diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php
index 205f75a4d..a0db1c2ba 100644
--- a/tests/Auth/OidcTest.php
+++ b/tests/Auth/OidcTest.php
@@ -5,6 +5,7 @@ namespace Tests\Auth;
 use BookStack\Activity\ActivityType;
 use BookStack\Facades\Theme;
 use BookStack\Theming\ThemeEvents;
+use BookStack\Uploads\UserAvatars;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
 use GuzzleHttp\Psr7\Response;
@@ -41,6 +42,7 @@ class OidcTest extends TestCase
             'oidc.discover'               => false,
             'oidc.dump_user_details'      => false,
             'oidc.additional_scopes'      => '',
+            'odic.fetch_avatar'           => false,
             'oidc.user_to_groups'         => false,
             'oidc.groups_claim'           => 'group',
             'oidc.remove_from_groups'     => false,
@@ -457,6 +459,105 @@ class OidcTest extends TestCase
         ]);
     }
 
+    public function test_user_avatar_fetched_from_picture_on_first_login_if_enabled()
+    {
+        config()->set(['oidc.fetch_avatar' => true]);
+
+        $this->runLogin([
+            'email' => 'avatar@example.com',
+            'picture' => 'https://example.com/my-avatar.jpg',
+        ], [
+            new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
+        ]);
+
+        $user = User::query()->where('email', '=', 'avatar@example.com')->first();
+        $this->assertNotNull($user);
+
+        $this->assertTrue($user->avatar()->exists());
+    }
+
+    public function test_user_avatar_fetched_for_existing_user_when_no_avatar_already_assigned()
+    {
+        config()->set(['oidc.fetch_avatar' => true]);
+        $editor = $this->users->editor();
+        $editor->external_auth_id = 'benny509';
+        $editor->save();
+
+        $this->assertFalse($editor->avatar()->exists());
+
+        $this->runLogin([
+            'picture' => 'https://example.com/my-avatar.jpg',
+            'sub' => 'benny509',
+        ], [
+            new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
+        ]);
+
+        $editor->refresh();
+        $this->assertTrue($editor->avatar()->exists());
+    }
+
+    public function test_user_avatar_not_fetched_if_image_data_format_unknown()
+    {
+        config()->set(['oidc.fetch_avatar' => true]);
+
+        $this->runLogin([
+            'email' => 'avatar-format@example.com',
+            'picture' => 'https://example.com/my-avatar.jpg',
+        ], [
+            new Response(200, ['Content-Type' => 'image/jpeg'], str_repeat('abc123', 5))
+        ]);
+
+        $user = User::query()->where('email', '=', 'avatar-format@example.com')->first();
+        $this->assertNotNull($user);
+
+        $this->assertFalse($user->avatar()->exists());
+    }
+
+    public function test_user_avatar_not_fetched_when_avatar_already_assigned()
+    {
+        config()->set(['oidc.fetch_avatar' => true]);
+        $editor = $this->users->editor();
+        $editor->external_auth_id = 'benny509';
+        $editor->save();
+
+        $avatars = $this->app->make(UserAvatars::class);
+        $originalImageData = $this->files->pngImageData();
+        $avatars->assignToUserFromExistingData($editor, $originalImageData, 'png');
+
+        $this->runLogin([
+            'picture' => 'https://example.com/my-avatar.jpg',
+            'sub' => 'benny509',
+        ], [
+            new Response(200, ['Content-Type' => 'image/jpeg'], $this->files->jpegImageData())
+        ]);
+
+        $editor->refresh();
+        $newAvatarData = file_get_contents($this->files->relativeToFullPath($editor->avatar->path));
+        $this->assertEquals($originalImageData, $newAvatarData);
+    }
+
+    public function test_user_avatar_fetch_follows_up_to_three_redirects()
+    {
+        config()->set(['oidc.fetch_avatar' => true]);
+
+        $logger = $this->withTestLogger();
+
+        $this->runLogin([
+            'email' => 'avatar@example.com',
+            'picture' => 'https://example.com/my-avatar.jpg',
+        ], [
+            new Response(302, ['Location' => 'https://example.com/a']),
+            new Response(302, ['Location' => 'https://example.com/b']),
+            new Response(302, ['Location' => 'https://example.com/c']),
+            new Response(302, ['Location' => 'https://example.com/d']),
+        ]);
+
+        $user = User::query()->where('email', '=', 'avatar@example.com')->first();
+        $this->assertFalse($user->avatar()->exists());
+
+        $this->assertStringContainsString('"Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: https://example.com/c"', $logger->getRecords()[0]->formatted);
+    }
+
     public function test_login_group_sync()
     {
         config()->set([
diff --git a/tests/Commands/CreateAdminCommandTest.php b/tests/Commands/CreateAdminCommandTest.php
index 95a39c497..f389dd942 100644
--- a/tests/Commands/CreateAdminCommandTest.php
+++ b/tests/Commands/CreateAdminCommandTest.php
@@ -2,8 +2,11 @@
 
 namespace Tests\Commands;
 
+use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
+use Illuminate\Support\Facades\Artisan;
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Hash;
 use Tests\TestCase;
 
 class CreateAdminCommandTest extends TestCase
@@ -11,14 +14,14 @@ class CreateAdminCommandTest extends TestCase
     public function test_standard_command_usage()
     {
         $this->artisan('bookstack:create-admin', [
-            '--email'    => 'admintest@example.com',
-            '--name'     => 'Admin Test',
+            '--email' => 'admintest@example.com',
+            '--name' => 'Admin Test',
             '--password' => 'testing-4',
         ])->assertExitCode(0);
 
         $this->assertDatabaseHas('users', [
             'email' => 'admintest@example.com',
-            'name'  => 'Admin Test',
+            'name' => 'Admin Test',
         ]);
 
         /** @var User $user */
@@ -30,14 +33,14 @@ class CreateAdminCommandTest extends TestCase
     public function test_providing_external_auth_id()
     {
         $this->artisan('bookstack:create-admin', [
-            '--email'            => 'admintest@example.com',
-            '--name'             => 'Admin Test',
+            '--email' => 'admintest@example.com',
+            '--name' => 'Admin Test',
             '--external-auth-id' => 'xX_admin_Xx',
         ])->assertExitCode(0);
 
         $this->assertDatabaseHas('users', [
-            'email'            => 'admintest@example.com',
-            'name'             => 'Admin Test',
+            'email' => 'admintest@example.com',
+            'name' => 'Admin Test',
             'external_auth_id' => 'xX_admin_Xx',
         ]);
 
@@ -50,14 +53,178 @@ class CreateAdminCommandTest extends TestCase
     {
         $this->artisan('bookstack:create-admin', [
             '--email' => 'admintest@example.com',
-            '--name'  => 'Admin Test',
+            '--name' => 'Admin Test',
         ])->expectsQuestion('Please specify a password for the new admin user (8 characters min)', 'hunter2000')
             ->assertExitCode(0);
 
         $this->assertDatabaseHas('users', [
             'email' => 'admintest@example.com',
-            'name'  => 'Admin Test',
+            'name' => 'Admin Test',
         ]);
         $this->assertTrue(Auth::attempt(['email' => 'admintest@example.com', 'password' => 'hunter2000']));
     }
+
+    public function test_generate_password_option()
+    {
+        $this->withoutMockingConsoleOutput()
+            ->artisan('bookstack:create-admin', [
+                '--email' => 'admintest@example.com',
+                '--name' => 'Admin Test',
+                '--generate-password' => true,
+            ]);
+
+        $output = trim(Artisan::output());
+        $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{32}$/', $output);
+
+        $user = User::query()->where('email', '=', 'admintest@example.com')->first();
+        $this->assertTrue(Hash::check($output, $user->password));
+    }
+
+    public function test_initial_option_updates_default_admin()
+    {
+        $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first();
+
+        $this->artisan('bookstack:create-admin', [
+            '--email' => 'firstadmin@example.com',
+            '--name' => 'Admin Test',
+            '--password' => 'testing-7',
+            '--initial' => true,
+        ])->expectsOutput('The default admin user has been updated with the provided details!')
+            ->assertExitCode(0);
+
+        $defaultAdmin->refresh();
+
+        $this->assertEquals('firstadmin@example.com', $defaultAdmin->email);
+    }
+
+    public function test_initial_option_does_not_update_if_only_non_default_admin_exists()
+    {
+        $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first();
+        $defaultAdmin->email = 'testadmin@example.com';
+        $defaultAdmin->save();
+
+        $this->artisan('bookstack:create-admin', [
+            '--email' => 'firstadmin@example.com',
+            '--name' => 'Admin Test',
+            '--password' => 'testing-7',
+            '--initial' => true,
+        ])->expectsOutput('Non-default admin user already exists. Skipping creation of new admin user.')
+            ->assertExitCode(2);
+
+        $defaultAdmin->refresh();
+
+        $this->assertEquals('testadmin@example.com', $defaultAdmin->email);
+    }
+
+    public function test_initial_option_updates_creates_new_admin_if_none_exists()
+    {
+        $adminRole = Role::getSystemRole('admin');
+        $adminRole->users()->delete();
+        $this->assertEquals(0, $adminRole->users()->count());
+
+        $this->artisan('bookstack:create-admin', [
+            '--email' => 'firstadmin@example.com',
+            '--name' => 'My initial admin',
+            '--password' => 'testing-7',
+            '--initial' => true,
+        ])->expectsOutput("Admin account with email \"firstadmin@example.com\" successfully created!")
+            ->assertExitCode(0);
+
+        $this->assertEquals(1, $adminRole->users()->count());
+        $this->assertDatabaseHas('users', [
+            'email' => 'firstadmin@example.com',
+            'name' => 'My initial admin',
+        ]);
+    }
+
+    public function test_initial_rerun_does_not_error_but_skips()
+    {
+        $adminRole = Role::getSystemRole('admin');
+        $adminRole->users()->delete();
+
+        $this->artisan('bookstack:create-admin', [
+            '--email' => 'firstadmin@example.com',
+            '--name' => 'My initial admin',
+            '--password' => 'testing-7',
+            '--initial' => true,
+        ])->expectsOutput("Admin account with email \"firstadmin@example.com\" successfully created!")
+            ->assertExitCode(0);
+
+        $this->artisan('bookstack:create-admin', [
+            '--email' => 'firstadmin@example.com',
+            '--name' => 'My initial admin',
+            '--password' => 'testing-7',
+            '--initial' => true,
+        ])->expectsOutput("Non-default admin user already exists. Skipping creation of new admin user.")
+            ->assertExitCode(2);
+    }
+
+    public function test_initial_option_creation_errors_if_email_already_exists()
+    {
+        $adminRole = Role::getSystemRole('admin');
+        $adminRole->users()->delete();
+        $editor = $this->users->editor();
+
+        $this->artisan('bookstack:create-admin', [
+            '--email' => $editor->email,
+            '--name' => 'My initial admin',
+            '--password' => 'testing-7',
+            '--initial' => true,
+        ])->expectsOutput("Could not create admin account.")
+            ->expectsOutput("An account with the email address \"{$editor->email}\" already exists.")
+            ->assertExitCode(1);
+    }
+
+    public function test_initial_option_updating_errors_if_email_already_exists()
+    {
+        $editor = $this->users->editor();
+        $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first();
+        $this->assertNotNull($defaultAdmin);
+
+        $this->artisan('bookstack:create-admin', [
+            '--email' => $editor->email,
+            '--name' => 'My initial admin',
+            '--password' => 'testing-7',
+            '--initial' => true,
+        ])->expectsOutput("Could not create admin account.")
+            ->expectsOutput("An account with the email address \"{$editor->email}\" already exists.")
+            ->assertExitCode(1);
+    }
+
+    public function test_initial_option_does_not_require_name_or_email_to_be_passed()
+    {
+        $adminRole = Role::getSystemRole('admin');
+        $adminRole->users()->delete();
+        $this->assertEquals(0, $adminRole->users()->count());
+
+        $this->artisan('bookstack:create-admin', [
+            '--generate-password' => true,
+            '--initial' => true,
+        ])->assertExitCode(0);
+
+        $this->assertEquals(1, $adminRole->users()->count());
+        $this->assertDatabaseHas('users', [
+            'email' => 'admin@example.com',
+            'name' => 'Admin',
+        ]);
+    }
+
+    public function test_initial_option_updating_existing_user_with_generate_password_only_outputs_password()
+    {
+        $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first();
+
+        $this->withoutMockingConsoleOutput()
+            ->artisan('bookstack:create-admin', [
+            '--email' => 'firstadmin@example.com',
+            '--name' => 'Admin Test',
+            '--generate-password' => true,
+            '--initial' => true,
+        ]);
+
+        $output = Artisan::output();
+        $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{32}$/', $output);
+
+        $defaultAdmin->refresh();
+        $this->assertEquals('firstadmin@example.com', $defaultAdmin->email);
+    }
 }
diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php
index d336e05a2..356a026a8 100644
--- a/tests/Commands/UpdateUrlCommandTest.php
+++ b/tests/Commands/UpdateUrlCommandTest.php
@@ -19,7 +19,7 @@ class UpdateUrlCommandTest extends TestCase
             ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with  \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y')
             ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y');
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'   => $page->id,
             'html' => '<a href="https://cats.example.com/donkeys"></a>',
         ]);
@@ -40,7 +40,7 @@ class UpdateUrlCommandTest extends TestCase
             ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y');
 
         foreach ($models as $model) {
-            $this->assertDatabaseHas($model->getTable(), [
+            $this->assertDatabaseHasEntityData($model->getMorphClass(), [
                 'id'               => $model->id,
                 'description_html' => '<a href="https://cats.example.com/donkeys"></a>',
             ]);
diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php
index fb9862931..3ba2c3e99 100644
--- a/tests/Entity/BookShelfTest.php
+++ b/tests/Entity/BookShelfTest.php
@@ -91,7 +91,7 @@ class BookShelfTest extends TestCase
         ]));
         $resp->assertRedirect();
         $editorId = $this->users->editor()->id;
-        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId]));
+        $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId]));
 
         $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first();
         $shelfPage = $this->get($shelf->getUrl());
@@ -117,11 +117,12 @@ class BookShelfTest extends TestCase
 
         $lastImage = Image::query()->orderByDesc('id')->firstOrFail();
         $shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first();
-        $this->assertDatabaseHas('bookshelves', [
-            'id'       => $shelf->id,
+        $this->assertDatabaseHas('entity_container_data', [
+            'entity_id'       => $shelf->id,
+            'entity_type' => 'bookshelf',
             'image_id' => $lastImage->id,
         ]);
-        $this->assertEquals($lastImage->id, $shelf->cover->id);
+        $this->assertEquals($lastImage->id, $shelf->coverInfo()->getImage()->id);
         $this->assertEquals('cover_bookshelf', $lastImage->type);
     }
 
@@ -247,7 +248,7 @@ class BookShelfTest extends TestCase
         $this->assertSessionHas('success');
 
         $editorId = $this->users->editor()->id;
-        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId]));
+        $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId]));
 
         $shelfPage = $this->get($shelf->getUrl());
         $shelfPage->assertSee($shelfInfo['name']);
@@ -259,6 +260,35 @@ class BookShelfTest extends TestCase
         $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]);
     }
 
+    public function test_shelf_edit_does_not_alter_books_we_dont_have_access_to()
+    {
+        $shelf = $this->entities->shelf();
+        $shelf->books()->detach();
+        $this->entities->book();
+        $this->entities->book();
+
+        $newBooks = [$this->entities->book(), $this->entities->book()];
+        $originalBooks = [$this->entities->book(), $this->entities->book()];
+        foreach ($originalBooks as $book) {
+            $this->permissions->disableEntityInheritedPermissions($book);
+            $shelf->books()->attach($book->id);
+        }
+
+        $this->asEditor()->put($shelf->getUrl(), [
+            'name' => $shelf->name,
+            'books' => "{$newBooks[0]->id},{$newBooks[1]->id}",
+        ])->assertRedirect($shelf->getUrl());
+
+        $resultingBooksById = $shelf->books()->get()->keyBy('id')->toArray();
+        $this->assertCount(4, $resultingBooksById);
+        foreach ($newBooks as $book) {
+            $this->assertArrayHasKey($book->id, $resultingBooksById);
+        }
+        foreach ($originalBooks as $book) {
+            $this->assertArrayHasKey($book->id, $resultingBooksById);
+        }
+    }
+
     public function test_shelf_create_new_book()
     {
         $shelf = $this->entities->shelf();
diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php
index 51bf65d10..a7142f037 100644
--- a/tests/Entity/BookTest.php
+++ b/tests/Entity/BookTest.php
@@ -27,7 +27,7 @@ class BookTest extends TestCase
 
         $resp = $this->get('/books/my-first-book');
         $resp->assertSee($book->name);
-        $resp->assertSee($book->description);
+        $resp->assertSee($book->descriptionInfo()->getPlain());
     }
 
     public function test_create_uses_different_slugs_when_name_reused()
@@ -238,30 +238,6 @@ class BookTest extends TestCase
         $this->assertEquals('list', setting()->getUser($editor, 'books_view_type'));
     }
 
-    public function test_slug_multi_byte_url_safe()
-    {
-        $book = $this->entities->newBook([
-            'name' => 'информация',
-        ]);
-
-        $this->assertEquals('informaciia', $book->slug);
-
-        $book = $this->entities->newBook([
-            'name' => '¿Qué?',
-        ]);
-
-        $this->assertEquals('que', $book->slug);
-    }
-
-    public function test_slug_format()
-    {
-        $book = $this->entities->newBook([
-            'name' => 'PartA / PartB / PartC',
-        ]);
-
-        $this->assertEquals('parta-partb-partc', $book->slug);
-    }
-
     public function test_description_limited_to_specific_html()
     {
         $book = $this->entities->book();
@@ -288,108 +264,4 @@ class BookTest extends TestCase
         $resp = $this->asEditor()->get($book->getUrl());
         $resp->assertSee("<p>My great<br>\ndescription<br>\n<br>\nwith newlines</p>", false);
     }
-
-    public function test_show_view_has_copy_button()
-    {
-        $book = $this->entities->book();
-        $resp = $this->asEditor()->get($book->getUrl());
-
-        $this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy');
-    }
-
-    public function test_copy_view()
-    {
-        $book = $this->entities->book();
-        $resp = $this->asEditor()->get($book->getUrl('/copy'));
-
-        $resp->assertOk();
-        $resp->assertSee('Copy Book');
-        $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]");
-    }
-
-    public function test_copy()
-    {
-        /** @var Book $book */
-        $book = Book::query()->whereHas('chapters')->whereHas('pages')->first();
-        $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
-
-        /** @var Book $copy */
-        $copy = Book::query()->where('name', '=', 'My copy book')->first();
-
-        $resp->assertRedirect($copy->getUrl());
-        $this->assertEquals($book->getDirectVisibleChildren()->count(), $copy->getDirectVisibleChildren()->count());
-
-        $this->get($copy->getUrl())->assertSee($book->description_html, false);
-    }
-
-    public function test_copy_does_not_copy_non_visible_content()
-    {
-        /** @var Book $book */
-        $book = Book::query()->whereHas('chapters')->whereHas('pages')->first();
-
-        // Hide child content
-        /** @var BookChild $page */
-        foreach ($book->getDirectVisibleChildren() as $child) {
-            $this->permissions->setEntityPermissions($child, [], []);
-        }
-
-        $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
-        /** @var Book $copy */
-        $copy = Book::query()->where('name', '=', 'My copy book')->first();
-
-        $this->assertEquals(0, $copy->getDirectVisibleChildren()->count());
-    }
-
-    public function test_copy_does_not_copy_pages_or_chapters_if_user_cant_create()
-    {
-        /** @var Book $book */
-        $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first();
-        $viewer = $this->users->viewer();
-        $this->permissions->grantUserRolePermissions($viewer, ['book-create-all']);
-
-        $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']);
-        /** @var Book $copy */
-        $copy = Book::query()->where('name', '=', 'My copy book')->first();
-
-        $this->assertEquals(0, $copy->pages()->count());
-        $this->assertEquals(0, $copy->chapters()->count());
-    }
-
-    public function test_copy_clones_cover_image_if_existing()
-    {
-        $book = $this->entities->book();
-        $bookRepo = $this->app->make(BookRepo::class);
-        $coverImageFile = $this->files->uploadedImage('cover.png');
-        $bookRepo->updateCoverImage($book, $coverImageFile);
-
-        $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
-        /** @var Book $copy */
-        $copy = Book::query()->where('name', '=', 'My copy book')->first();
-
-        $this->assertNotNull($copy->cover);
-        $this->assertNotEquals($book->cover->id, $copy->cover->id);
-    }
-
-    public function test_copy_adds_book_to_shelves_if_edit_permissions_allows()
-    {
-        /** @var Bookshelf $shelfA */
-        /** @var Bookshelf $shelfB */
-        [$shelfA, $shelfB] = Bookshelf::query()->take(2)->get();
-        $book = $this->entities->book();
-
-        $shelfA->appendBook($book);
-        $shelfB->appendBook($book);
-
-        $viewer = $this->users->viewer();
-        $this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']);
-        $this->permissions->setEntityPermissions($shelfB);
-
-
-        $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
-        /** @var Book $copy */
-        $copy = Book::query()->where('name', '=', 'My copy book')->first();
-
-        $this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists());
-        $this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists());
-    }
 }
diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php
index 1577cee76..0c0ec7841 100644
--- a/tests/Entity/ChapterTest.php
+++ b/tests/Entity/ChapterTest.php
@@ -66,90 +66,7 @@ class ChapterTest extends TestCase
         $this->assertNotificationContains($redirectReq, 'Chapter Successfully Deleted');
     }
 
-    public function test_show_view_has_copy_button()
-    {
-        $chapter = $this->entities->chapter();
-
-        $resp = $this->asEditor()->get($chapter->getUrl());
-        $this->withHtml($resp)->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy');
-    }
-
-    public function test_copy_view()
-    {
-        $chapter = $this->entities->chapter();
-
-        $resp = $this->asEditor()->get($chapter->getUrl('/copy'));
-        $resp->assertOk();
-        $resp->assertSee('Copy Chapter');
-        $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]");
-        $this->withHtml($resp)->assertElementExists('input[name="entity_selection"]');
-    }
-
-    public function test_copy()
-    {
-        /** @var Chapter $chapter */
-        $chapter = Chapter::query()->whereHas('pages')->first();
-        /** @var Book $otherBook */
-        $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first();
-
-        $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [
-            'name'             => 'My copied chapter',
-            'entity_selection' => 'book:' . $otherBook->id,
-        ]);
-
-        /** @var Chapter $newChapter */
-        $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
-
-        $resp->assertRedirect($newChapter->getUrl());
-        $this->assertEquals($otherBook->id, $newChapter->book_id);
-        $this->assertEquals($chapter->pages->count(), $newChapter->pages->count());
-    }
-
-    public function test_copy_does_not_copy_non_visible_pages()
-    {
-        $chapter = $this->entities->chapterHasPages();
 
-        // Hide pages to all non-admin roles
-        /** @var Page $page */
-        foreach ($chapter->pages as $page) {
-            $this->permissions->setEntityPermissions($page, [], []);
-        }
-
-        $this->asEditor()->post($chapter->getUrl('/copy'), [
-            'name' => 'My copied chapter',
-        ]);
-
-        /** @var Chapter $newChapter */
-        $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
-        $this->assertEquals(0, $newChapter->pages()->count());
-    }
-
-    public function test_copy_does_not_copy_pages_if_user_cant_page_create()
-    {
-        $chapter = $this->entities->chapterHasPages();
-        $viewer = $this->users->viewer();
-        $this->permissions->grantUserRolePermissions($viewer, ['chapter-create-all']);
-
-        // Lacking permission results in no copied pages
-        $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [
-            'name' => 'My copied chapter',
-        ]);
-
-        /** @var Chapter $newChapter */
-        $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
-        $this->assertEquals(0, $newChapter->pages()->count());
-
-        $this->permissions->grantUserRolePermissions($viewer, ['page-create-all']);
-
-        // Having permission rules in copied pages
-        $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [
-            'name' => 'My copied again chapter',
-        ]);
-
-        /** @var Chapter $newChapter2 */
-        $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first();
-        $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count());
-    }
 
     public function test_sort_book_action_visible_if_permissions_allow()
     {
diff --git a/tests/Entity/CommentDisplayTest.php b/tests/Entity/CommentDisplayTest.php
new file mode 100644
index 000000000..80664890a
--- /dev/null
+++ b/tests/Entity/CommentDisplayTest.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Activity\Models\Comment;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class CommentDisplayTest extends TestCase
+{
+    public function test_reply_comments_are_nested()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
+        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
+
+        $respHtml = $this->withHtml($this->get($page->getUrl()));
+        $respHtml->assertElementCount('.comment-branch', 3);
+        $respHtml->assertElementNotExists('.comment-branch .comment-branch');
+
+        $comment = $page->comments()->first();
+        $resp = $this->postJson("/comment/$page->id", [
+            'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id
+        ]);
+        $resp->assertStatus(200);
+
+        $respHtml = $this->withHtml($this->get($page->getUrl()));
+        $respHtml->assertElementCount('.comment-branch', 4);
+        $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment');
+    }
+
+    public function test_comments_are_visible_in_the_page_editor()
+    {
+        $page = $this->entities->page();
+
+        $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']);
+
+        $respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
+        $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
+    }
+
+    public function test_comment_creator_name_truncated()
+    {
+        [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']);
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes());
+
+        $pageResp = $this->asAdmin()->get($page->getUrl());
+        $pageResp->assertSee('Wolfeschlegels…');
+    }
+
+    public function test_comment_editor_js_loaded_with_create_or_edit_permissions()
+    {
+        $editor = $this->users->editor();
+        $page = $this->entities->page();
+
+        $resp = $this->actingAs($editor)->get($page->getUrl());
+        $resp->assertSee('window.editor_translations', false);
+        $resp->assertSee('component="entity-selector"', false);
+
+        $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']);
+        $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']);
+
+        $resp = $this->actingAs($editor)->get($page->getUrl());
+        $resp->assertDontSee('window.editor_translations', false);
+        $resp->assertDontSee('component="entity-selector"', false);
+
+        Comment::factory()->create([
+            'created_by'  => $editor->id,
+            'commentable_type' => 'page',
+            'commentable_id'   => $page->id,
+        ]);
+
+        $resp = $this->actingAs($editor)->get($page->getUrl());
+        $resp->assertSee('window.editor_translations', false);
+        $resp->assertSee('component="entity-selector"', false);
+    }
+
+    public function test_comment_displays_relative_times()
+    {
+        $page = $this->entities->page();
+        $comment = Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => $page->getMorphClass()]);
+        $comment->created_at = now()->subWeek();
+        $comment->updated_at = now()->subDay();
+        $comment->save();
+
+        $pageResp = $this->asAdmin()->get($page->getUrl());
+        $html = $this->withHtml($pageResp);
+
+        // Create date shows relative time as text to user
+        $html->assertElementContains('.comment-box', 'commented 1 week ago');
+        // Updated indicator has full time as title
+        $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') .  '"]', 'Updated');
+    }
+
+    public function test_comment_displays_reference_if_set()
+    {
+        $page = $this->entities->page();
+        $comment = Comment::factory()->make([
+            'content_ref' => 'bkmrk-a:abc:4-1',
+            'local_id'   =>  10,
+        ]);
+        $page->comments()->save($comment);
+
+        $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+        $html->assertElementExists('#comment10 .comment-reference-indicator-wrap a');
+    }
+
+    public function test_archived_comments_are_shown_in_their_own_container()
+    {
+        $page = $this->entities->page();
+        $comment = Comment::factory()->make(['local_id' => 44]);
+        $page->comments()->save($comment);
+
+        $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+        $html->assertElementExists('#comment-tab-panel-active #comment44');
+        $html->assertElementNotExists('#comment-tab-panel-archived .comment-box');
+
+        $comment->archived = true;
+        $comment->save();
+
+        $html = $this->withHtml($this->asEditor()->get($page->getUrl()));
+        $html->assertElementExists('#comment-tab-panel-archived #comment44.comment-box');
+        $html->assertElementNotExists('#comment-tab-panel-active #comment44');
+    }
+}
diff --git a/tests/Entity/CommentStoreTest.php b/tests/Entity/CommentStoreTest.php
new file mode 100644
index 000000000..c4c959c29
--- /dev/null
+++ b/tests/Entity/CommentStoreTest.php
@@ -0,0 +1,276 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Activity\ActivityType;
+use BookStack\Activity\Models\Comment;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class CommentStoreTest extends TestCase
+{
+    public function test_add_comment()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => 'page', 'local_id' => 2]);
+        $comment = Comment::factory()->make(['parent_id' => 2]);
+        $resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
+
+        $resp->assertStatus(200);
+        $resp->assertSee($comment->html, false);
+
+        $pageResp = $this->get($page->getUrl());
+        $pageResp->assertSee($comment->html, false);
+
+        $this->assertDatabaseHas('comments', [
+            'local_id'    => 3,
+            'commentable_id'   => $page->id,
+            'commentable_type' => 'page',
+            'parent_id'   => 2,
+        ]);
+
+        $this->assertActivityExists(ActivityType::COMMENT_CREATE);
+    }
+    public function test_add_comment_stores_content_reference_only_if_format_valid()
+    {
+        $validityByRefs = [
+            'bkmrk-my-title:4589284922:4-3' => true,
+            'bkmrk-my-title:4589284922:' => true,
+            'bkmrk-my-title:4589284922:abc' => false,
+            'my-title:4589284922:' => false,
+            'bkmrk-my-title-4589284922:' => false,
+        ];
+
+        $page = $this->entities->page();
+
+        foreach ($validityByRefs as $ref => $valid) {
+            $this->asAdmin()->postJson("/comment/$page->id", [
+                'html' => '<p>My comment</p>',
+                'parent_id' => null,
+                'content_ref' => $ref,
+            ]);
+
+            if ($valid) {
+                $this->assertDatabaseHas('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]);
+            } else {
+                $this->assertDatabaseMissing('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]);
+            }
+        }
+    }
+
+    public function test_comment_edit()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $this->postJson("/comment/$page->id", $comment->getAttributes());
+
+        $comment = $page->comments()->first();
+        $newHtml = '<p>updated text content</p>';
+        $resp = $this->putJson("/comment/$comment->id", [
+            'html' => $newHtml,
+        ]);
+
+        $resp->assertStatus(200);
+        $resp->assertSee($newHtml, false);
+        $resp->assertDontSee($comment->html, false);
+
+        $this->assertDatabaseHas('comments', [
+            'html'      => $newHtml,
+            'commentable_id' => $page->id,
+        ]);
+
+        $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+    }
+
+    public function test_comment_delete()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $this->postJson("/comment/$page->id", $comment->getAttributes());
+
+        $comment = $page->comments()->first();
+
+        $resp = $this->delete("/comment/$comment->id");
+        $resp->assertStatus(200);
+
+        $this->assertDatabaseMissing('comments', [
+            'id' => $comment->id,
+        ]);
+
+        $this->assertActivityExists(ActivityType::COMMENT_DELETE);
+    }
+
+    public function test_comment_archive_and_unarchive()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $comment->refresh();
+
+        $this->put("/comment/$comment->id/archive");
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'archived' => true,
+        ]);
+
+        $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+
+        $this->put("/comment/$comment->id/unarchive");
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'archived' => false,
+        ]);
+
+        $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+    }
+
+    public function test_archive_endpoints_require_delete_or_edit_permissions()
+    {
+        $viewer = $this->users->viewer();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $comment->refresh();
+
+        $endpoints = ["/comment/$comment->id/archive", "/comment/$comment->id/unarchive"];
+
+        foreach ($endpoints as $endpoint) {
+            $resp = $this->actingAs($viewer)->put($endpoint);
+            $this->assertPermissionError($resp);
+        }
+
+        $this->permissions->grantUserRolePermissions($viewer, ['comment-delete-all']);
+
+        foreach ($endpoints as $endpoint) {
+            $resp = $this->actingAs($viewer)->put($endpoint);
+            $resp->assertOk();
+        }
+
+        $this->permissions->removeUserRolePermissions($viewer, ['comment-delete-all']);
+        $this->permissions->grantUserRolePermissions($viewer, ['comment-update-all']);
+
+        foreach ($endpoints as $endpoint) {
+            $resp = $this->actingAs($viewer)->put($endpoint);
+            $resp->assertOk();
+        }
+    }
+
+    public function test_non_top_level_comments_cant_be_archived_or_unarchived()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $subComment = Comment::factory()->make(['parent_id' => $comment->id]);
+        $page->comments()->save($subComment);
+        $subComment->refresh();
+
+        $resp = $this->putJson("/comment/$subComment->id/archive");
+        $resp->assertStatus(400);
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $subComment->id,
+            'archived' => false,
+        ]);
+
+        $resp = $this->putJson("/comment/$subComment->id/unarchive");
+        $resp->assertStatus(400);
+    }
+
+    public function test_scripts_cannot_be_injected_via_comment_html()
+    {
+        $page = $this->entities->page();
+
+        $script = '<script>const a = "script";</script><script>const b = "sneakyscript";</script><p onclick="1">My lovely comment</p>';
+        $this->asAdmin()->postJson("/comment/$page->id", [
+            'html' => $script,
+        ]);
+
+        $pageView = $this->get($page->getUrl());
+        $pageView->assertDontSee($script, false);
+        $pageView->assertDontSee('sneakyscript', false);
+        $pageView->assertSee('<p>My lovely comment</p>', false);
+
+        $comment = $page->comments()->first();
+        $this->putJson("/comment/$comment->id", [
+            'html' => $script . '<p>updated</p>',
+        ]);
+
+        $pageView = $this->get($page->getUrl());
+        $pageView->assertDontSee($script, false);
+        $pageView->assertDontSee('sneakyscript', false);
+        $pageView->assertSee('<p>My lovely comment</p><p>updated</p>');
+    }
+
+    public function test_scripts_are_removed_even_if_already_in_db()
+    {
+        $page = $this->entities->page();
+        Comment::factory()->create([
+            'html' => '<script>superbadscript</script><script>superbadscript</script><p onclick="superbadonclick">scriptincommentest</p>',
+            'commentable_type' => 'page', 'commentable_id' => $page
+        ]);
+
+        $resp = $this->asAdmin()->get($page->getUrl());
+        $resp->assertSee('scriptincommentest', false);
+        $resp->assertDontSee('superbadscript', false);
+        $resp->assertDontSee('superbadonclick', false);
+    }
+
+    public function test_comment_html_is_limited()
+    {
+        $page = $this->entities->page();
+        $input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" data-a="b">a</a><section>Hello</section><section>there</section></p>';
+        $expected = '<p>Content<a href="#cat">a</a></p>';
+
+        $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+           'commentable_type' => 'page',
+           'commentable_id' => $page->id,
+           'html' => $expected,
+        ]);
+
+        $comment = $page->comments()->first();
+        $resp = $this->put("/comment/{$comment->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+            'id'   => $comment->id,
+            'html' => $expected,
+        ]);
+    }
+
+    public function test_comment_html_spans_are_cleaned()
+    {
+        $page = $this->entities->page();
+        $input = '<p><span class="beans">Hello</span> do you have <span style="white-space: discard;">biscuits</span>?</p>';
+        $expected = '<p><span>Hello</span> do you have <span>biscuits</span>?</p>';
+
+        $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+            'commentable_type' => 'page',
+            'commentable_id' => $page->id,
+            'html' => $expected,
+        ]);
+
+        $comment = $page->comments()->first();
+        $resp = $this->put("/comment/{$comment->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+            'id'   => $comment->id,
+            'html' => $expected,
+        ]);
+    }
+}
diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php
deleted file mode 100644
index 9e019e3d1..000000000
--- a/tests/Entity/CommentTest.php
+++ /dev/null
@@ -1,234 +0,0 @@
-<?php
-
-namespace Tests\Entity;
-
-use BookStack\Activity\ActivityType;
-use BookStack\Activity\Models\Comment;
-use BookStack\Entities\Models\Page;
-use Tests\TestCase;
-
-class CommentTest extends TestCase
-{
-    public function test_add_comment()
-    {
-        $this->asAdmin();
-        $page = $this->entities->page();
-
-        $comment = Comment::factory()->make(['parent_id' => 2]);
-        $resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
-
-        $resp->assertStatus(200);
-        $resp->assertSee($comment->html, false);
-
-        $pageResp = $this->get($page->getUrl());
-        $pageResp->assertSee($comment->html, false);
-
-        $this->assertDatabaseHas('comments', [
-            'local_id'    => 1,
-            'entity_id'   => $page->id,
-            'entity_type' => Page::newModelInstance()->getMorphClass(),
-            'text'        => null,
-            'parent_id'   => 2,
-        ]);
-
-        $this->assertActivityExists(ActivityType::COMMENT_CREATE);
-    }
-
-    public function test_comment_edit()
-    {
-        $this->asAdmin();
-        $page = $this->entities->page();
-
-        $comment = Comment::factory()->make();
-        $this->postJson("/comment/$page->id", $comment->getAttributes());
-
-        $comment = $page->comments()->first();
-        $newHtml = '<p>updated text content</p>';
-        $resp = $this->putJson("/comment/$comment->id", [
-            'html' => $newHtml,
-        ]);
-
-        $resp->assertStatus(200);
-        $resp->assertSee($newHtml, false);
-        $resp->assertDontSee($comment->html, false);
-
-        $this->assertDatabaseHas('comments', [
-            'html'      => $newHtml,
-            'entity_id' => $page->id,
-        ]);
-
-        $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
-    }
-
-    public function test_comment_delete()
-    {
-        $this->asAdmin();
-        $page = $this->entities->page();
-
-        $comment = Comment::factory()->make();
-        $this->postJson("/comment/$page->id", $comment->getAttributes());
-
-        $comment = $page->comments()->first();
-
-        $resp = $this->delete("/comment/$comment->id");
-        $resp->assertStatus(200);
-
-        $this->assertDatabaseMissing('comments', [
-            'id' => $comment->id,
-        ]);
-
-        $this->assertActivityExists(ActivityType::COMMENT_DELETE);
-    }
-
-    public function test_scripts_cannot_be_injected_via_comment_html()
-    {
-        $page = $this->entities->page();
-
-        $script = '<script>const a = "script";</script><p onclick="1">My lovely comment</p>';
-        $this->asAdmin()->postJson("/comment/$page->id", [
-            'html' => $script,
-        ]);
-
-        $pageView = $this->get($page->getUrl());
-        $pageView->assertDontSee($script, false);
-        $pageView->assertSee('<p>My lovely comment</p>', false);
-
-        $comment = $page->comments()->first();
-        $this->putJson("/comment/$comment->id", [
-            'html' => $script . '<p>updated</p>',
-        ]);
-
-        $pageView = $this->get($page->getUrl());
-        $pageView->assertDontSee($script, false);
-        $pageView->assertSee('<p>My lovely comment</p><p>updated</p>');
-    }
-
-    public function test_scripts_are_removed_even_if_already_in_db()
-    {
-        $page = $this->entities->page();
-        Comment::factory()->create([
-            'html' => '<script>superbadscript</script><p onclick="superbadonclick">scriptincommentest</p>',
-            'entity_type' => 'page', 'entity_id' => $page
-        ]);
-
-        $resp = $this->asAdmin()->get($page->getUrl());
-        $resp->assertSee('scriptincommentest', false);
-        $resp->assertDontSee('superbadscript', false);
-        $resp->assertDontSee('superbadonclick', false);
-    }
-
-    public function test_comment_html_is_limited()
-    {
-        $page = $this->entities->page();
-        $input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" data-a="b">a</a><section>Hello</section></p>';
-        $expected = '<p>Content<a href="#cat">a</a></p>';
-
-        $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
-        $resp->assertOk();
-        $this->assertDatabaseHas('comments', [
-           'entity_type' => 'page',
-           'entity_id' => $page->id,
-           'html' => $expected,
-        ]);
-
-        $comment = $page->comments()->first();
-        $resp = $this->put("/comment/{$comment->id}", ['html' => $input]);
-        $resp->assertOk();
-        $this->assertDatabaseHas('comments', [
-            'id'   => $comment->id,
-            'html' => $expected,
-        ]);
-    }
-
-    public function test_reply_comments_are_nested()
-    {
-        $this->asAdmin();
-        $page = $this->entities->page();
-
-        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
-        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
-
-        $respHtml = $this->withHtml($this->get($page->getUrl()));
-        $respHtml->assertElementCount('.comment-branch', 3);
-        $respHtml->assertElementNotExists('.comment-branch .comment-branch');
-
-        $comment = $page->comments()->first();
-        $resp = $this->postJson("/comment/$page->id", [
-            'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id
-        ]);
-        $resp->assertStatus(200);
-
-        $respHtml = $this->withHtml($this->get($page->getUrl()));
-        $respHtml->assertElementCount('.comment-branch', 4);
-        $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment');
-    }
-
-    public function test_comments_are_visible_in_the_page_editor()
-    {
-        $page = $this->entities->page();
-
-        $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']);
-
-        $respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
-        $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
-    }
-
-    public function test_comment_creator_name_truncated()
-    {
-        [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']);
-        $page = $this->entities->page();
-
-        $comment = Comment::factory()->make();
-        $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes());
-
-        $pageResp = $this->asAdmin()->get($page->getUrl());
-        $pageResp->assertSee('Wolfeschlegels…');
-    }
-
-    public function test_comment_editor_js_loaded_with_create_or_edit_permissions()
-    {
-        $editor = $this->users->editor();
-        $page = $this->entities->page();
-
-        $resp = $this->actingAs($editor)->get($page->getUrl());
-        $resp->assertSee('tinymce.min.js?', false);
-        $resp->assertSee('window.editor_translations', false);
-        $resp->assertSee('component="entity-selector"', false);
-
-        $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']);
-        $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']);
-
-        $resp = $this->actingAs($editor)->get($page->getUrl());
-        $resp->assertDontSee('tinymce.min.js?', false);
-        $resp->assertDontSee('window.editor_translations', false);
-        $resp->assertDontSee('component="entity-selector"', false);
-
-        Comment::factory()->create([
-            'created_by'  => $editor->id,
-            'entity_type' => 'page',
-            'entity_id'   => $page->id,
-        ]);
-
-        $resp = $this->actingAs($editor)->get($page->getUrl());
-        $resp->assertSee('tinymce.min.js?', false);
-        $resp->assertSee('window.editor_translations', false);
-        $resp->assertSee('component="entity-selector"', false);
-    }
-
-    public function test_comment_displays_relative_times()
-    {
-        $page = $this->entities->page();
-        $comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]);
-        $comment->created_at = now()->subWeek();
-        $comment->updated_at = now()->subDay();
-        $comment->save();
-
-        $pageResp = $this->asAdmin()->get($page->getUrl());
-        $html = $this->withHtml($pageResp);
-
-        // Create date shows relative time as text to user
-        $html->assertElementContains('.comment-box', 'commented 1 week ago');
-        // Updated indicator has full time as title
-        $html->assertElementContains('.comment-box span[title^="Updated ' . $comment->updated_at->format('Y-m-d') .  '"]', 'Updated');
-    }
-}
diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php
index d9b1ee466..8658e7699 100644
--- a/tests/Entity/ConvertTest.php
+++ b/tests/Entity/ConvertTest.php
@@ -35,8 +35,8 @@ class ConvertTest extends TestCase
         /** @var Book $newBook */
         $newBook = Book::query()->orderBy('id', 'desc')->first();
 
-        $this->assertDatabaseMissing('chapters', ['id' => $chapter->id]);
-        $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]);
+        $this->assertDatabaseMissing('entities', ['id' => $chapter->id, 'type' => 'chapter']);
+        $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]);
         $this->assertCount(1, $newBook->tags);
         $this->assertEquals('Category', $newBook->tags->first()->name);
         $this->assertEquals('Penguins', $newBook->tags->first()->value);
@@ -100,7 +100,7 @@ class ConvertTest extends TestCase
 
         // Checks for new shelf
         $resp->assertRedirectContains('/shelves/');
-        $this->assertDatabaseMissing('chapters', ['id' => $childChapter->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $childChapter->id, 'type' => 'chapter']);
         $this->assertCount(1, $newShelf->tags);
         $this->assertEquals('Category', $newShelf->tags->first()->name);
         $this->assertEquals('Ducks', $newShelf->tags->first()->value);
@@ -112,8 +112,8 @@ class ConvertTest extends TestCase
         $this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf);
 
         // Checks for old book to contain child pages
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'name' => $book->name . ' Pages']);
-        $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => 0]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'name' => $book->name . ' Pages']);
+        $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => null]);
 
         // Checks for nested page
         $chapterChildPage->refresh();
diff --git a/tests/Entity/CopyTest.php b/tests/Entity/CopyTest.php
new file mode 100644
index 000000000..d4b6d54cf
--- /dev/null
+++ b/tests/Entity/CopyTest.php
@@ -0,0 +1,399 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\BookRepo;
+use Tests\TestCase;
+
+class CopyTest extends TestCase
+{
+    public function test_book_show_view_has_copy_button()
+    {
+        $book = $this->entities->book();
+        $resp = $this->asEditor()->get($book->getUrl());
+
+        $this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy');
+    }
+
+    public function test_book_copy_view()
+    {
+        $book = $this->entities->book();
+        $resp = $this->asEditor()->get($book->getUrl('/copy'));
+
+        $resp->assertOk();
+        $resp->assertSee('Copy Book');
+        $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]");
+    }
+
+    public function test_book_copy()
+    {
+        /** @var Book $book */
+        $book = Book::query()->whereHas('chapters')->whereHas('pages')->first();
+        $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
+
+        /** @var Book $copy */
+        $copy = Book::query()->where('name', '=', 'My copy book')->first();
+
+        $resp->assertRedirect($copy->getUrl());
+        $this->assertEquals($book->getDirectVisibleChildren()->count(), $copy->getDirectVisibleChildren()->count());
+
+        $this->get($copy->getUrl())->assertSee($book->description_html, false);
+    }
+
+    public function test_book_copy_does_not_copy_non_visible_content()
+    {
+        /** @var Book $book */
+        $book = Book::query()->whereHas('chapters')->whereHas('pages')->first();
+
+        // Hide child content
+        /** @var BookChild $page */
+        foreach ($book->getDirectVisibleChildren() as $child) {
+            $this->permissions->setEntityPermissions($child, [], []);
+        }
+
+        $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
+        /** @var Book $copy */
+        $copy = Book::query()->where('name', '=', 'My copy book')->first();
+
+        $this->assertEquals(0, $copy->getDirectVisibleChildren()->count());
+    }
+
+    public function test_book_copy_does_not_copy_pages_or_chapters_if_user_cant_create()
+    {
+        /** @var Book $book */
+        $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first();
+        $viewer = $this->users->viewer();
+        $this->permissions->grantUserRolePermissions($viewer, ['book-create-all']);
+
+        $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']);
+        /** @var Book $copy */
+        $copy = Book::query()->where('name', '=', 'My copy book')->first();
+
+        $this->assertEquals(0, $copy->pages()->count());
+        $this->assertEquals(0, $copy->chapters()->count());
+    }
+
+    public function test_book_copy_clones_cover_image_if_existing()
+    {
+        $book = $this->entities->book();
+        $bookRepo = $this->app->make(BookRepo::class);
+        $coverImageFile = $this->files->uploadedImage('cover.png');
+        $bookRepo->updateCoverImage($book, $coverImageFile);
+
+        $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect();
+        /** @var Book $copy */
+        $copy = Book::query()->where('name', '=', 'My copy book')->first();
+
+        $this->assertNotNull($copy->coverInfo()->getImage());
+        $this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id);
+    }
+
+    public function test_book_copy_adds_book_to_shelves_if_edit_permissions_allows()
+    {
+        /** @var Bookshelf $shelfA */
+        /** @var Bookshelf $shelfB */
+        [$shelfA, $shelfB] = Bookshelf::query()->take(2)->get();
+        $book = $this->entities->book();
+
+        $shelfA->appendBook($book);
+        $shelfB->appendBook($book);
+
+        $viewer = $this->users->viewer();
+        $this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']);
+        $this->permissions->setEntityPermissions($shelfB);
+
+
+        $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
+        /** @var Book $copy */
+        $copy = Book::query()->where('name', '=', 'My copy book')->first();
+
+        $this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists());
+        $this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists());
+    }
+
+    public function test_chapter_show_view_has_copy_button()
+    {
+        $chapter = $this->entities->chapter();
+
+        $resp = $this->asEditor()->get($chapter->getUrl());
+        $this->withHtml($resp)->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy');
+    }
+
+    public function test_chapter_copy_view()
+    {
+        $chapter = $this->entities->chapter();
+
+        $resp = $this->asEditor()->get($chapter->getUrl('/copy'));
+        $resp->assertOk();
+        $resp->assertSee('Copy Chapter');
+        $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]");
+        $this->withHtml($resp)->assertElementExists('input[name="entity_selection"]');
+    }
+
+    public function test_chapter_copy()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->whereHas('pages')->first();
+        /** @var Book $otherBook */
+        $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first();
+
+        $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [
+            'name'             => 'My copied chapter',
+            'entity_selection' => 'book:' . $otherBook->id,
+        ]);
+
+        /** @var Chapter $newChapter */
+        $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
+
+        $resp->assertRedirect($newChapter->getUrl());
+        $this->assertEquals($otherBook->id, $newChapter->book_id);
+        $this->assertEquals($chapter->pages->count(), $newChapter->pages->count());
+    }
+
+    public function test_chapter_copy_does_not_copy_non_visible_pages()
+    {
+        $chapter = $this->entities->chapterHasPages();
+
+        // Hide pages to all non-admin roles
+        /** @var Page $page */
+        foreach ($chapter->pages as $page) {
+            $this->permissions->setEntityPermissions($page, [], []);
+        }
+
+        $this->asEditor()->post($chapter->getUrl('/copy'), [
+            'name' => 'My copied chapter',
+        ]);
+
+        /** @var Chapter $newChapter */
+        $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
+        $this->assertEquals(0, $newChapter->pages()->count());
+    }
+
+    public function test_chapter_copy_does_not_copy_pages_if_user_cant_page_create()
+    {
+        $chapter = $this->entities->chapterHasPages();
+        $viewer = $this->users->viewer();
+        $this->permissions->grantUserRolePermissions($viewer, ['chapter-create-all']);
+
+        // Lacking permission results in no copied pages
+        $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [
+            'name' => 'My copied chapter',
+        ]);
+
+        /** @var Chapter $newChapter */
+        $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
+        $this->assertEquals(0, $newChapter->pages()->count());
+
+        $this->permissions->grantUserRolePermissions($viewer, ['page-create-all']);
+
+        // Having permission rules in copied pages
+        $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [
+            'name' => 'My copied again chapter',
+        ]);
+
+        /** @var Chapter $newChapter2 */
+        $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first();
+        $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count());
+    }
+
+    public function test_book_copy_updates_internal_references()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        /** @var Chapter $chapter */
+        $chapter = $book->chapters()->first();
+        /** @var Page $page */
+        $page = $chapter->pages()->first();
+        $this->asEditor();
+        $this->entities->updatePage($page, [
+            'name' => 'reference test page',
+            'html' => '<p>This is a test <a href="' . $book->getUrl() . '">book link</a></p>',
+        ]);
+
+        // Quick pre-update to get stable slug
+        $this->put($book->getUrl(), ['name' => 'Internal ref test']);
+        $book->refresh();
+        $page->refresh();
+
+        $html = '<p>This is a test <a href="' . $page->getUrl() . '">page link</a></p>';
+        $this->put($book->getUrl(), ['name' => 'Internal ref test', 'description_html' => $html]);
+
+        $this->post($book->getUrl('/copy'), ['name' => 'My copied book']);
+
+        $newBook = Book::query()->where('name', '=', 'My copied book')->first();
+        $newPage = $newBook->pages()->where('name', '=', 'reference test page')->first();
+
+        $this->assertStringContainsString($newBook->getUrl(), $newPage->html);
+        $this->assertStringContainsString($newPage->getUrl(), $newBook->description_html);
+
+        $this->assertStringNotContainsString($book->getUrl(), $newPage->html);
+        $this->assertStringNotContainsString($page->getUrl(), $newBook->description_html);
+    }
+
+    public function test_chapter_copy_updates_internal_references()
+    {
+        $chapter = $this->entities->chapterHasPages();
+        /** @var Page $page */
+        $page = $chapter->pages()->first();
+        $this->asEditor();
+        $this->entities->updatePage($page, [
+            'name' => 'reference test page',
+            'html' => '<p>This is a test <a href="' . $chapter->getUrl() . '">chapter link</a></p>',
+        ]);
+
+        // Quick pre-update to get stable slug
+        $this->put($chapter->getUrl(), ['name' => 'Internal ref test']);
+        $chapter->refresh();
+        $page->refresh();
+
+        $html = '<p>This is a test <a href="' . $page->getUrl() . '">page link</a></p>';
+        $this->put($chapter->getUrl(), ['name' => 'Internal ref test', 'description_html' => $html]);
+
+        $this->post($chapter->getUrl('/copy'), ['name' => 'My copied chapter']);
+
+        $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
+        $newPage = $newChapter->pages()->where('name', '=', 'reference test page')->first();
+
+        $this->assertStringContainsString($newChapter->getUrl() . '"', $newPage->html);
+        $this->assertStringContainsString($newPage->getUrl() . '"', $newChapter->description_html);
+
+        $this->assertStringNotContainsString($chapter->getUrl() . '"', $newPage->html);
+        $this->assertStringNotContainsString($page->getUrl() . '"', $newChapter->description_html);
+    }
+
+    public function test_chapter_copy_updates_internal_permalink_references_in_its_description()
+    {
+        $chapter = $this->entities->chapterHasPages();
+        /** @var Page $page */
+        $page = $chapter->pages()->first();
+
+        $this->asEditor()->put($chapter->getUrl(), [
+            'name' => 'Internal ref test',
+            'description_html' => '<p>This is a test <a href="' . $page->getPermalink() . '">page link</a></p>',
+        ]);
+        $chapter->refresh();
+
+        $this->post($chapter->getUrl('/copy'), ['name' => 'My copied chapter']);
+        $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first();
+
+        $this->assertStringContainsString('/link/', $newChapter->description_html);
+        $this->assertStringNotContainsString($page->getPermalink() . '"', $newChapter->description_html);
+    }
+
+    public function test_page_copy_updates_internal_self_references()
+    {
+        $page = $this->entities->page();
+        $this->asEditor();
+
+        // Initial update to get stable slug
+        $this->entities->updatePage($page, ['name' => 'reference test page']);
+
+        $page->refresh();
+        $this->entities->updatePage($page, [
+            'name' => 'reference test page',
+            'html' => '<p>This is a test <a href="' . $page->getUrl() . '">page link</a></p>',
+        ]);
+
+        $this->post($page->getUrl('/copy'), ['name' => 'My copied page']);
+        $newPage = Page::query()->where('name', '=', 'My copied page')->first();
+        $this->assertNotNull($newPage);
+
+        $this->assertStringContainsString($newPage->getUrl(), $newPage->html);
+        $this->assertStringNotContainsString($page->getUrl(), $newPage->html);
+    }
+
+    public function test_page_copy()
+    {
+        $page = $this->entities->page();
+        $page->html = '<p>This is some test content</p>';
+        $page->save();
+
+        $currentBook = $page->book;
+        $newBook = Book::where('id', '!=', $currentBook->id)->first();
+
+        $resp = $this->asEditor()->get($page->getUrl('/copy'));
+        $resp->assertSee('Copy Page');
+
+        $movePageResp = $this->post($page->getUrl('/copy'), [
+            'entity_selection' => 'book:' . $newBook->id,
+            'name'             => 'My copied test page',
+        ]);
+        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
+
+        $movePageResp->assertRedirect($pageCopy->getUrl());
+        $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book');
+        $this->assertStringContainsString('This is some test content', $pageCopy->html);
+    }
+
+    public function test_page_copy_with_markdown_has_both_html_and_markdown()
+    {
+        $page = $this->entities->page();
+        $page->html = '<h1>This is some test content</h1>';
+        $page->markdown = '# This is some test content';
+        $page->save();
+        $newBook = Book::where('id', '!=', $page->book->id)->first();
+
+        $this->asEditor()->post($page->getUrl('/copy'), [
+            'entity_selection' => 'book:' . $newBook->id,
+            'name'             => 'My copied test page',
+        ]);
+        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
+
+        $this->assertStringContainsString('This is some test content', $pageCopy->html);
+        $this->assertEquals('# This is some test content', $pageCopy->markdown);
+    }
+
+    public function test_page_copy_with_no_destination()
+    {
+        $page = $this->entities->page();
+        $currentBook = $page->book;
+
+        $resp = $this->asEditor()->get($page->getUrl('/copy'));
+        $resp->assertSee('Copy Page');
+
+        $movePageResp = $this->post($page->getUrl('/copy'), [
+            'name' => 'My copied test page',
+        ]);
+
+        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
+
+        $movePageResp->assertRedirect($pageCopy->getUrl());
+        $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book');
+        $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance');
+    }
+
+    public function test_page_can_be_copied_without_edit_permission()
+    {
+        $page = $this->entities->page();
+        $currentBook = $page->book;
+        $newBook = Book::where('id', '!=', $currentBook->id)->first();
+        $viewer = $this->users->viewer();
+
+        $resp = $this->actingAs($viewer)->get($page->getUrl());
+        $resp->assertDontSee($page->getUrl('/copy'));
+
+        $newBook->owned_by = $viewer->id;
+        $newBook->save();
+        $this->permissions->grantUserRolePermissions($viewer, ['page-create-own']);
+        $this->permissions->regenerateForEntity($newBook);
+
+        $resp = $this->actingAs($viewer)->get($page->getUrl());
+        $resp->assertSee($page->getUrl('/copy'));
+
+        $movePageResp = $this->post($page->getUrl('/copy'), [
+            'entity_selection' => 'book:' . $newBook->id,
+            'name'             => 'My copied test page',
+        ]);
+        $movePageResp->assertRedirect();
+
+        $this->assertDatabaseHasEntityData('page', [
+            'name'       => 'My copied test page',
+            'created_by' => $viewer->id,
+            'book_id'    => $newBook->id,
+        ]);
+    }
+}
diff --git a/tests/Entity/DefaultTemplateTest.php b/tests/Entity/DefaultTemplateTest.php
index 5369a5430..d3109c8a2 100644
--- a/tests/Entity/DefaultTemplateTest.php
+++ b/tests/Entity/DefaultTemplateTest.php
@@ -18,7 +18,7 @@ class DefaultTemplateTest extends TestCase
         ];
 
         $this->asEditor()->post('/books', $details);
-        $this->assertDatabaseHas('books', $details);
+        $this->assertDatabaseHasEntityData('book', $details);
     }
 
     public function test_creating_chapter_with_default_template()
@@ -31,7 +31,7 @@ class DefaultTemplateTest extends TestCase
         ];
 
         $this->asEditor()->post($book->getUrl('/create-chapter'), $details);
-        $this->assertDatabaseHas('chapters', $details);
+        $this->assertDatabaseHasEntityData('chapter', $details);
     }
 
     public function test_updating_book_with_default_template()
@@ -40,10 +40,10 @@ class DefaultTemplateTest extends TestCase
         $templatePage = $this->entities->templatePage();
 
         $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
 
         $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => '']);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]);
     }
 
     public function test_updating_chapter_with_default_template()
@@ -52,10 +52,10 @@ class DefaultTemplateTest extends TestCase
         $templatePage = $this->entities->templatePage();
 
         $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => strval($templatePage->id)]);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
 
         $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => '']);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]);
     }
 
     public function test_default_book_template_cannot_be_set_if_not_a_template()
@@ -65,7 +65,7 @@ class DefaultTemplateTest extends TestCase
         $this->assertFalse($page->template);
 
         $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]);
     }
 
     public function test_default_chapter_template_cannot_be_set_if_not_a_template()
@@ -75,7 +75,7 @@ class DefaultTemplateTest extends TestCase
         $this->assertFalse($page->template);
 
         $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $page->id]);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]);
     }
 
 
@@ -86,7 +86,7 @@ class DefaultTemplateTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($templatePage);
 
         $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]);
     }
 
     public function test_default_chapter_template_cannot_be_set_if_not_have_access()
@@ -96,7 +96,7 @@ class DefaultTemplateTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($templatePage);
 
         $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]);
     }
 
     public function test_inaccessible_book_default_template_can_be_set_if_unchanged()
@@ -106,7 +106,7 @@ class DefaultTemplateTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($templatePage);
 
         $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]);
-        $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
+        $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]);
     }
 
     public function test_inaccessible_chapter_default_template_can_be_set_if_unchanged()
@@ -116,7 +116,7 @@ class DefaultTemplateTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($templatePage);
 
         $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]);
-        $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
+        $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]);
     }
 
     public function test_default_page_template_option_shows_on_book_form()
@@ -173,7 +173,7 @@ class DefaultTemplateTest extends TestCase
         $templatePage->forceFill(['html' => '<p>My template page</p>', 'markdown' => '# My template page'])->save();
         $book = $this->bookUsingDefaultTemplate($templatePage);
 
-        $this->asEditor()->get($book->getUrl('/create-page'));
+        $this->asEditor()->get($book->getUrl('/create-page'))->assertRedirect();
         $latestPage = $book->pages()
             ->where('draft', '=', true)
             ->where('template', '=', false)
@@ -251,7 +251,7 @@ class DefaultTemplateTest extends TestCase
 
         $this->post($book->getUrl('/create-guest-page'), [
             'name' => 'My guest page with template'
-        ]);
+        ])->assertRedirect();
         $latestBookPage = $book->pages()
             ->where('draft', '=', false)
             ->where('template', '=', false)
diff --git a/tests/Entity/EntityQueryTest.php b/tests/Entity/EntityQueryTest.php
new file mode 100644
index 000000000..180cb3076
--- /dev/null
+++ b/tests/Entity/EntityQueryTest.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use Illuminate\Database\Eloquent\Builder;
+use Tests\TestCase;
+
+class EntityQueryTest extends TestCase
+{
+    public function test_basic_entity_query_has_join_and_type_applied()
+    {
+        $query = Book::query();
+        $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where `type` = ? and `entities`.`deleted_at` is null';
+        $this->assertEquals($expected, $query->toSql());
+        $this->assertEquals(['book', 'book'], $query->getBindings());
+    }
+
+    public function test_joins_in_sub_queries_use_alias_names()
+    {
+        $query = Book::query()->whereHas('chapters', function (Builder $query) {
+            $query->where('name', '=', 'a');
+        });
+
+        // Probably from type limits on relation where not needed?
+        $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where exists (select * from `entities` as `laravel_reserved_%d` left join `entity_container_data` on `entity_container_data`.`entity_id` = `laravel_reserved_%d`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`id` = `laravel_reserved_%d`.`book_id` and `name` = ? and `type` = ? and `laravel_reserved_%d`.`deleted_at` is null) and `type` = ? and `entities`.`deleted_at` is null';
+        $this->assertStringMatchesFormat($expected, $query->toSql());
+        $this->assertEquals(['book', 'chapter', 'a', 'chapter', 'book'], $query->getBindings());
+    }
+
+    public function test_book_chapter_relation_applies_type_condition()
+    {
+        $book = $this->entities->book();
+        $query = $book->chapters();
+        $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`book_id` = ? and `entities`.`book_id` is not null and `type` = ? and `entities`.`deleted_at` is null';
+        $this->assertEquals($expected, $query->toSql());
+        $this->assertEquals(['chapter', $book->id, 'chapter'], $query->getBindings());
+
+        $query = Book::query()->whereHas('chapters');
+        $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where exists (select * from `entities` as `laravel_reserved_%d` left join `entity_container_data` on `entity_container_data`.`entity_id` = `laravel_reserved_%d`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`id` = `laravel_reserved_%d`.`book_id` and `type` = ? and `laravel_reserved_%d`.`deleted_at` is null) and `type` = ? and `entities`.`deleted_at` is null';
+        $this->assertStringMatchesFormat($expected, $query->toSql());
+        $this->assertEquals(['book', 'chapter', 'chapter', 'book'], $query->getBindings());
+    }
+}
diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php
index e99ba9b81..2623acd3f 100644
--- a/tests/Entity/PageDraftTest.php
+++ b/tests/Entity/PageDraftTest.php
@@ -204,7 +204,7 @@ class PageDraftTest extends TestCase
         ]);
         $resp->assertOk();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'       => $draft->id,
             'draft'    => true,
             'name'     => 'My updated draft',
@@ -235,7 +235,7 @@ class PageDraftTest extends TestCase
             'markdown' => '# My markdown page',
         ]);
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'    => $draft->id,
             'draft' => false,
             'slug'  => 'my-page',
diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php
index bb3d74193..d98b1f998 100644
--- a/tests/Entity/PageEditorTest.php
+++ b/tests/Entity/PageEditorTest.php
@@ -85,7 +85,7 @@ class PageEditorTest extends TestCase
         $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
         $resp->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'markdown' => $details['markdown'],
             'id'       => $draft->id,
             'draft'    => false,
@@ -193,7 +193,7 @@ class PageEditorTest extends TestCase
         $editLink = $this->page->getUrl('/edit') . '?editor=';
         $this->withHtml($resp)->assertElementContains("a[href=\"{$editLink}markdown-clean\"]", '(Clean Content)');
         $this->withHtml($resp)->assertElementContains("a[href=\"{$editLink}markdown-stable\"]", '(Stable Content)');
-        $this->withHtml($resp)->assertElementContains("a[href=\"{$editLink}wysiwyg2024\"]", '(In Alpha Testing)');
+        $this->withHtml($resp)->assertElementContains("a[href=\"{$editLink}wysiwyg2024\"]", '(In Beta Testing)');
 
         $resp = $this->asAdmin()->get($this->page->getUrl('/edit?editor=markdown-stable'));
         $editLink = $this->page->getUrl('/edit') . '?editor=';
diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php
index 9040254f7..3828bd06e 100644
--- a/tests/Entity/PageRevisionTest.php
+++ b/tests/Entity/PageRevisionTest.php
@@ -91,7 +91,7 @@ class PageRevisionTest extends TestCase
         $restoreReq->assertRedirect($page->getUrl());
 
         $pageView = $this->get($page->getUrl());
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'       => $page->id,
             'markdown' => '## New Content def456',
         ]);
diff --git a/tests/Entity/PageTemplateTest.php b/tests/Entity/PageTemplateTest.php
index 6a68c3ab1..9c867a534 100644
--- a/tests/Entity/PageTemplateTest.php
+++ b/tests/Entity/PageTemplateTest.php
@@ -35,7 +35,7 @@ class PageTemplateTest extends TestCase
         ];
 
         $this->put($page->getUrl(), $pageUpdateData);
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'       => $page->id,
             'template' => false,
         ]);
@@ -43,7 +43,7 @@ class PageTemplateTest extends TestCase
         $this->permissions->grantUserRolePermissions($editor, ['templates-manage']);
 
         $this->put($page->getUrl(), $pageUpdateData);
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id'       => $page->id,
             'template' => true,
         ]);
diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php
index e444d165f..1b2f3c9fe 100644
--- a/tests/Entity/PageTest.php
+++ b/tests/Entity/PageTest.php
@@ -4,6 +4,7 @@ namespace Tests\Entity;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Page;
+use BookStack\Uploads\Image;
 use Carbon\Carbon;
 use Tests\TestCase;
 
@@ -74,7 +75,7 @@ class PageTest extends TestCase
         $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
         $resp->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'markdown' => $details['markdown'],
             'name'     => $details['name'],
             'id'       => $draft->id,
@@ -158,117 +159,23 @@ class PageTest extends TestCase
         ]);
     }
 
-    public function test_page_copy()
+    public function test_page_full_delete_nulls_related_images()
     {
         $page = $this->entities->page();
-        $page->html = '<p>This is some test content</p>';
-        $page->save();
-
-        $currentBook = $page->book;
-        $newBook = Book::where('id', '!=', $currentBook->id)->first();
-
-        $resp = $this->asEditor()->get($page->getUrl('/copy'));
-        $resp->assertSee('Copy Page');
-
-        $movePageResp = $this->post($page->getUrl('/copy'), [
-            'entity_selection' => 'book:' . $newBook->id,
-            'name'             => 'My copied test page',
-        ]);
-        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
-
-        $movePageResp->assertRedirect($pageCopy->getUrl());
-        $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book');
-        $this->assertStringContainsString('This is some test content', $pageCopy->html);
-    }
-
-    public function test_page_copy_with_markdown_has_both_html_and_markdown()
-    {
-        $page = $this->entities->page();
-        $page->html = '<h1>This is some test content</h1>';
-        $page->markdown = '# This is some test content';
-        $page->save();
-        $newBook = Book::where('id', '!=', $page->book->id)->first();
-
-        $this->asEditor()->post($page->getUrl('/copy'), [
-            'entity_selection' => 'book:' . $newBook->id,
-            'name'             => 'My copied test page',
-        ]);
-        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
-
-        $this->assertStringContainsString('This is some test content', $pageCopy->html);
-        $this->assertEquals('# This is some test content', $pageCopy->markdown);
-    }
-
-    public function test_page_copy_with_no_destination()
-    {
-        $page = $this->entities->page();
-        $currentBook = $page->book;
-
-        $resp = $this->asEditor()->get($page->getUrl('/copy'));
-        $resp->assertSee('Copy Page');
-
-        $movePageResp = $this->post($page->getUrl('/copy'), [
-            'name' => 'My copied test page',
-        ]);
-
-        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
-
-        $movePageResp->assertRedirect($pageCopy->getUrl());
-        $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book');
-        $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance');
-    }
+        $image = Image::factory()->create(['type' => 'gallery', 'uploaded_to' => $page->id]);
 
-    public function test_page_can_be_copied_without_edit_permission()
-    {
-        $page = $this->entities->page();
-        $currentBook = $page->book;
-        $newBook = Book::where('id', '!=', $currentBook->id)->first();
-        $viewer = $this->users->viewer();
-
-        $resp = $this->actingAs($viewer)->get($page->getUrl());
-        $resp->assertDontSee($page->getUrl('/copy'));
-
-        $newBook->owned_by = $viewer->id;
-        $newBook->save();
-        $this->permissions->grantUserRolePermissions($viewer, ['page-create-own']);
-        $this->permissions->regenerateForEntity($newBook);
-
-        $resp = $this->actingAs($viewer)->get($page->getUrl());
-        $resp->assertSee($page->getUrl('/copy'));
-
-        $movePageResp = $this->post($page->getUrl('/copy'), [
-            'entity_selection' => 'book:' . $newBook->id,
-            'name'             => 'My copied test page',
-        ]);
-        $movePageResp->assertRedirect();
-
-        $this->assertDatabaseHas('pages', [
-            'name'       => 'My copied test page',
-            'created_by' => $viewer->id,
-            'book_id'    => $newBook->id,
-        ]);
-    }
-
-    public function test_old_page_slugs_redirect_to_new_pages()
-    {
-        $page = $this->entities->page();
+        $this->asEditor()->delete($page->getUrl());
+        $this->asAdmin()->post('/settings/recycle-bin/empty');
 
-        // Need to save twice since revisions are not generated in seeder.
-        $this->asAdmin()->put($page->getUrl(), [
-            'name' => 'super test',
-            'html' => '<p></p>',
+        $this->assertDatabaseMissing('images', [
+            'type' => 'gallery',
+            'uploaded_to' => $page->id,
         ]);
 
-        $page->refresh();
-        $pageUrl = $page->getUrl();
-
-        $this->put($pageUrl, [
-            'name' => 'super test page',
-            'html' => '<p></p>',
+        $this->assertDatabaseHas('images', [
+            'id' => $image->id,
+            'uploaded_to' => null,
         ]);
-
-        $this->get($pageUrl)
-            ->assertRedirect("/books/{$page->book->slug}/page/super-test-page");
     }
 
     public function test_page_within_chapter_deletion_returns_to_chapter()
@@ -356,4 +263,14 @@ class PageTest extends TestCase
         $resp = $this->get('/');
         $this->withHtml($resp)->assertElementContains('#recently-updated-pages', $page->name);
     }
+
+    public function test_page_edit_without_update_permissions_but_with_view_redirects_to_page()
+    {
+        $page = $this->entities->page();
+
+        $resp = $this->asViewer()->get($page->getUrl('/edit'));
+        $resp->assertRedirect($page->getUrl());
+
+        $resp->assertSessionHas('error', 'You do not have permission to access the requested page.');
+    }
 }
diff --git a/tests/Entity/SlugTest.php b/tests/Entity/SlugTest.php
new file mode 100644
index 000000000..51cf34e5d
--- /dev/null
+++ b/tests/Entity/SlugTest.php
@@ -0,0 +1,212 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\SlugHistory;
+use Tests\TestCase;
+
+class SlugTest extends TestCase
+{
+    public function test_slug_multi_byte_url_safe()
+    {
+        $book = $this->entities->newBook([
+            'name' => 'информация',
+        ]);
+
+        $this->assertEquals('informaciia', $book->slug);
+
+        $book = $this->entities->newBook([
+            'name' => '¿Qué?',
+        ]);
+
+        $this->assertEquals('que', $book->slug);
+    }
+
+    public function test_slug_format()
+    {
+        $book = $this->entities->newBook([
+            'name' => 'PartA / PartB / PartC',
+        ]);
+
+        $this->assertEquals('parta-partb-partc', $book->slug);
+    }
+
+    public function test_old_page_slugs_redirect_to_new_pages()
+    {
+        $page = $this->entities->page();
+        $pageUrl = $page->getUrl();
+
+        $this->asAdmin()->put($pageUrl, [
+            'name' => 'super test page',
+            'html' => '<p></p>',
+        ]);
+
+        $this->get($pageUrl)
+            ->assertRedirect("/books/{$page->book->slug}/page/super-test-page");
+    }
+
+    public function test_old_shelf_slugs_redirect_to_new_shelf()
+    {
+        $shelf = $this->entities->shelf();
+        $shelfUrl = $shelf->getUrl();
+
+        $this->asAdmin()->put($shelf->getUrl(), [
+            'name' => 'super test shelf',
+        ]);
+
+        $this->get($shelfUrl)
+            ->assertRedirect("/shelves/super-test-shelf");
+    }
+
+    public function test_old_book_slugs_redirect_to_new_book()
+    {
+        $book = $this->entities->book();
+        $bookUrl = $book->getUrl();
+
+        $this->asAdmin()->put($book->getUrl(), [
+            'name' => 'super test book',
+        ]);
+
+        $this->get($bookUrl)
+            ->assertRedirect("/books/super-test-book");
+    }
+
+    public function test_old_chapter_slugs_redirect_to_new_chapter()
+    {
+        $chapter = $this->entities->chapter();
+        $chapterUrl = $chapter->getUrl();
+
+        $this->asAdmin()->put($chapter->getUrl(), [
+            'name' => 'super test chapter',
+        ]);
+
+        $this->get($chapterUrl)
+            ->assertRedirect("/books/{$chapter->book->slug}/chapter/super-test-chapter");
+    }
+
+    public function test_old_book_slugs_in_page_urls_redirect_to_current_page_url()
+    {
+        $page = $this->entities->page();
+        $book = $page->book;
+        $pageUrl = $page->getUrl();
+
+        $this->asAdmin()->put($book->getUrl(), [
+            'name' => 'super test book',
+        ]);
+
+        $this->get($pageUrl)
+            ->assertRedirect("/books/super-test-book/page/{$page->slug}");
+    }
+
+    public function test_old_book_slugs_in_chapter_urls_redirect_to_current_chapter_url()
+    {
+        $chapter = $this->entities->chapter();
+        $book = $chapter->book;
+        $chapterUrl = $chapter->getUrl();
+
+        $this->asAdmin()->put($book->getUrl(), [
+            'name' => 'super test book',
+        ]);
+
+        $this->get($chapterUrl)
+            ->assertRedirect("/books/super-test-book/chapter/{$chapter->slug}");
+    }
+
+    public function test_slug_lookup_controlled_by_permissions()
+    {
+        $editor = $this->users->editor();
+        $pageA = $this->entities->page();
+        $pageB = $this->entities->page();
+
+        SlugHistory::factory()->create(['sluggable_id' => $pageA->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()]);
+        SlugHistory::factory()->create(['sluggable_id' => $pageB->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()->subDay()]);
+
+        // Defaults to latest where visible
+        $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl());
+
+        $this->permissions->disableEntityInheritedPermissions($pageA);
+
+        // Falls back to other entry where the latest is not visible
+        $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageB->getUrl());
+
+        // Original still accessible where permissions allow
+        $this->asAdmin()->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl());
+    }
+
+    public function test_slugs_recorded_in_history_on_page_update()
+    {
+        $page = $this->entities->page();
+        $this->asAdmin()->put($page->getUrl(), [
+            'name' => 'new slug',
+            'html' => '<p></p>',
+        ]);
+
+        $oldSlug = $page->slug;
+        $page->refresh();
+        $this->assertNotEquals($oldSlug, $page->slug);
+
+        $this->assertDatabaseHas('slug_history', [
+            'sluggable_id' => $page->id,
+            'sluggable_type' => 'page',
+            'slug' => $oldSlug,
+            'parent_slug' => $page->book->slug,
+        ]);
+    }
+
+    public function test_slugs_recorded_in_history_on_chapter_update()
+    {
+        $chapter = $this->entities->chapter();
+        $this->asAdmin()->put($chapter->getUrl(), [
+            'name' => 'new slug',
+        ]);
+
+        $oldSlug = $chapter->slug;
+        $chapter->refresh();
+        $this->assertNotEquals($oldSlug, $chapter->slug);
+
+        $this->assertDatabaseHas('slug_history', [
+            'sluggable_id' => $chapter->id,
+            'sluggable_type' => 'chapter',
+            'slug' => $oldSlug,
+            'parent_slug' => $chapter->book->slug,
+        ]);
+    }
+
+    public function test_slugs_recorded_in_history_on_book_update()
+    {
+        $book = $this->entities->book();
+        $this->asAdmin()->put($book->getUrl(), [
+            'name' => 'new slug',
+        ]);
+
+        $oldSlug = $book->slug;
+        $book->refresh();
+        $this->assertNotEquals($oldSlug, $book->slug);
+
+        $this->assertDatabaseHas('slug_history', [
+            'sluggable_id' => $book->id,
+            'sluggable_type' => 'book',
+            'slug' => $oldSlug,
+            'parent_slug' => null,
+        ]);
+    }
+
+    public function test_slugs_recorded_in_history_on_shelf_update()
+    {
+        $shelf = $this->entities->shelf();
+        $this->asAdmin()->put($shelf->getUrl(), [
+            'name' => 'new slug',
+        ]);
+
+        $oldSlug = $shelf->slug;
+        $shelf->refresh();
+        $this->assertNotEquals($oldSlug, $shelf->slug);
+
+        $this->assertDatabaseHas('slug_history', [
+            'sluggable_id' => $shelf->id,
+            'sluggable_type' => 'bookshelf',
+            'slug' => $oldSlug,
+            'parent_slug' => null,
+        ]);
+    }
+}
diff --git a/tests/Entity/TagTest.php b/tests/Entity/TagTest.php
index 729f93903..63f037d9c 100644
--- a/tests/Entity/TagTest.php
+++ b/tests/Entity/TagTest.php
@@ -230,4 +230,39 @@ class TagTest extends TestCase
         $resp->assertDontSee('tag-name-<>', false);
         $resp->assertSee('tag-name-&lt;&gt;', false);
     }
+
+    public function test_parent_tag_classes_visible()
+    {
+        $page = $this->entities->pageWithinChapter();
+        $page->chapter->tags()->create(['name' => 'My Chapter Tag', 'value' => 'abc123']);
+        $page->book->tags()->create(['name' => 'My Book Tag', 'value' => 'def456']);
+        $this->asEditor();
+
+        $html = $this->withHtml($this->get($page->getUrl()));
+        $html->assertElementExists('body.chapter-tag-pair-mychaptertag-abc123');
+        $html->assertElementExists('body.book-tag-pair-mybooktag-def456');
+
+        $html = $this->withHtml($this->get($page->chapter->getUrl()));
+        $html->assertElementExists('body.book-tag-pair-mybooktag-def456');
+    }
+
+    public function test_parent_tag_classes_not_visible_if_cannot_see_parent()
+    {
+        $page = $this->entities->pageWithinChapter();
+        $page->chapter->tags()->create(['name' => 'My Chapter Tag', 'value' => 'abc123']);
+        $page->book->tags()->create(['name' => 'My Book Tag', 'value' => 'def456']);
+        $editor = $this->users->editor();
+        $this->actingAs($editor);
+
+        $this->permissions->setEntityPermissions($page, ['view'], [$editor->roles()->first()]);
+        $this->permissions->disableEntityInheritedPermissions($page->chapter);
+
+        $html = $this->withHtml($this->get($page->getUrl()));
+        $html->assertElementNotExists('body.chapter-tag-pair-mychaptertag-abc123');
+        $html->assertElementExists('body.book-tag-pair-mybooktag-def456');
+
+        $this->permissions->disableEntityInheritedPermissions($page->book);
+        $html = $this->withHtml($this->get($page->getUrl()));
+        $html->assertElementNotExists('body.book-tag-pair-mybooktag-def456');
+    }
 }
diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php
index 069cf2801..e039fb2cc 100644
--- a/tests/Exports/HtmlExportTest.php
+++ b/tests/Exports/HtmlExportTest.php
@@ -99,9 +99,9 @@ class HtmlExportTest extends TestCase
         $page = $this->entities->page();
 
         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss'));
+        $resp->assertSee($page->created_at->format('Y-m-d H:i:s T'));
         $resp->assertDontSee($page->created_at->diffForHumans());
-        $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss'));
+        $resp->assertSee($page->updated_at->format('Y-m-d H:i:s T'));
         $resp->assertDontSee($page->updated_at->diffForHumans());
     }
 
diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php
index 3bccd4682..6bf585d59 100644
--- a/tests/Exports/MarkdownExportTest.php
+++ b/tests/Exports/MarkdownExportTest.php
@@ -84,7 +84,7 @@ class MarkdownExportTest extends TestCase
         $this->asEditor()->get($book->getUrl('/create-page'));
         $this->get($book->getUrl('/create-page'));
 
-        [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get();
+        [$pageA, $pageB] = $book->pages()->whereNull('chapter_id')->get();
         $pageA->html = '<p>hello tester</p>';
         $pageA->save();
         $pageB->name = 'The second page in this test';
diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php
index 1434c013f..063f6d942 100644
--- a/tests/Exports/ZipExportTest.php
+++ b/tests/Exports/ZipExportTest.php
@@ -41,7 +41,7 @@ class ZipExportTest extends TestCase
     {
         $page = $this->entities->page();
         $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
 
         $this->assertEquals($page->id, $zip->data['page']['id'] ?? null);
         $this->assertArrayNotHasKey('book', $zip->data);
@@ -83,7 +83,7 @@ class ZipExportTest extends TestCase
     {
         $page = $this->entities->page();
         $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
 
         $pageData = $zip->data['page'];
         $this->assertEquals([
@@ -105,7 +105,7 @@ class ZipExportTest extends TestCase
         $page->save();
 
         $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
 
         $pageData = $zip->data['page'];
         $this->assertEquals($markdown, $pageData['markdown']);
@@ -121,7 +121,7 @@ class ZipExportTest extends TestCase
         ]);
 
         $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
 
         $pageData = $zip->data['page'];
         $this->assertEquals([
@@ -147,7 +147,7 @@ class ZipExportTest extends TestCase
         $image = Image::findOrFail($result['response']->id);
 
         $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $pageData = $zip->data['page'];
 
         $this->assertCount(1, $pageData['images']);
@@ -173,7 +173,7 @@ class ZipExportTest extends TestCase
         $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'PageAttachmentExport.txt', $contents, 'text/plain');
 
         $zipResp = $this->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
 
         $pageData = $zip->data['page'];
         $this->assertCount(1, $pageData['attachments']);
@@ -203,7 +203,7 @@ class ZipExportTest extends TestCase
         ]);
 
         $zipResp = $this->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
 
         $pageData = $zip->data['page'];
         $this->assertCount(1, $pageData['attachments']);
@@ -221,13 +221,13 @@ class ZipExportTest extends TestCase
         $book->tags()->saveMany(Tag::factory()->count(2)->make());
 
         $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $this->assertArrayHasKey('book', $zip->data);
 
         $bookData = $zip->data['book'];
         $this->assertEquals($book->id, $bookData['id']);
         $this->assertEquals($book->name, $bookData['name']);
-        $this->assertEquals($book->descriptionHtml(), $bookData['description_html']);
+        $this->assertEquals($book->descriptionInfo()->getHtml(), $bookData['description_html']);
         $this->assertCount(2, $bookData['tags']);
         $this->assertCount($book->directPages()->count(), $bookData['pages']);
         $this->assertCount($book->chapters()->count(), $bookData['chapters']);
@@ -240,10 +240,10 @@ class ZipExportTest extends TestCase
         $bookRepo = $this->app->make(BookRepo::class);
         $coverImageFile = $this->files->uploadedImage('cover.png');
         $bookRepo->updateCoverImage($book, $coverImageFile);
-        $coverImage = $book->cover()->first();
+        $coverImage = $book->coverInfo()->getImage();
 
         $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
 
         $this->assertArrayHasKey('cover', $zip->data['book']);
         $coverRef = $zip->data['book']['cover'];
@@ -258,13 +258,13 @@ class ZipExportTest extends TestCase
         $chapter->tags()->saveMany(Tag::factory()->count(2)->make());
 
         $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $this->assertArrayHasKey('chapter', $zip->data);
 
         $chapterData = $zip->data['chapter'];
         $this->assertEquals($chapter->id, $chapterData['id']);
         $this->assertEquals($chapter->name, $chapterData['name']);
-        $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']);
+        $this->assertEquals($chapter->descriptionInfo()->getHtml(), $chapterData['description_html']);
         $this->assertCount(2, $chapterData['tags']);
         $this->assertEquals($chapter->priority, $chapterData['priority']);
         $this->assertCount($chapter->pages()->count(), $chapterData['pages']);
@@ -284,18 +284,18 @@ class ZipExportTest extends TestCase
         $page->save();
 
         $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $this->assertCount(0, $zip->data['book']['chapters'][0]['pages'] ?? ['cat']);
 
         $zipResp = $this->actingAs($editor)->get($chapter->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $this->assertCount(0, $zip->data['chapter']['pages'] ?? ['cat']);
 
         $page->chapter_id = 0;
         $page->save();
 
         $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $this->assertCount(0, $zip->data['book']['pages'] ?? ['cat']);
     }
 
@@ -314,7 +314,7 @@ class ZipExportTest extends TestCase
         $page->save();
 
         $zipResp = $this->asEditor()->get($book->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $bookData = $zip->data['book'];
         $chapterData = $bookData['chapters'][0];
         $pageData = $chapterData['pages'][0];
@@ -342,7 +342,7 @@ class ZipExportTest extends TestCase
         $chapter->save();
 
         $zipResp = $this->get($book->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $bookData = $zip->data['book'];
         $chapterData = $bookData['chapters'][0];
 
@@ -367,13 +367,61 @@ class ZipExportTest extends TestCase
         $page->save();
 
         $zipResp = $this->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $pageData = $zip->data['page'];
 
         $ref = '[[bsexport:image:' . $image->id . ']]';
         $this->assertStringContainsString("<a href=\"{$ref}\">Original URL</a><a href=\"{$ref}\">Storage URL</a>", $pageData['html']);
     }
 
+    public function test_orphaned_images_can_be_used_on_default_local_storage()
+    {
+        $this->asEditor();
+        $page = $this->entities->page();
+        $result = $this->files->uploadGalleryImageToPage($this, $page);
+        $displayThumb = $result['response']->thumbs->gallery ?? '';
+        $page->html = '<p><img src="' . $displayThumb . '" alt="My image"></p>';
+        $page->save();
+
+        $image = Image::findOrFail($result['response']->id);
+        $image->uploaded_to = null;
+        $image->save();
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zipResp->assertOk();
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $this->assertCount(1, $pageData['images']);
+        $imageData = $pageData['images'][0];
+        $this->assertEquals($image->id, $imageData['id']);
+
+        $this->assertEquals('<p><img src="[[bsexport:image:' . $imageData['id'] . ']]" alt="My image"></p>', $pageData['html']);
+    }
+
+    public function test_orphaned_images_cannot_be_used_on_local_secure_restricted()
+    {
+        config()->set('filesystems.images', 'local_secure_restricted');
+
+        $this->asEditor();
+        $page = $this->entities->page();
+        $result = $this->files->uploadGalleryImageToPage($this, $page);
+        $displayThumb = $result['response']->thumbs->gallery ?? '';
+        $page->html = '<p><img src="' . $displayThumb . '" alt="My image"></p>';
+        $page->save();
+
+        $image = Image::findOrFail($result['response']->id);
+        $image->uploaded_to = null;
+        $image->save();
+
+        $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
+        $zipResp->assertOk();
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
+        $pageData = $zip->data['page'];
+
+        $this->assertCount(0, $pageData['images']);
+    }
+
     public function test_cross_reference_links_external_to_export_are_not_converted()
     {
         $page = $this->entities->page();
@@ -381,7 +429,7 @@ class ZipExportTest extends TestCase
         $page->save();
 
         $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $pageData = $zip->data['page'];
 
         $this->assertStringContainsString('href="' . $page->book->getUrl() . '"', $pageData['html']);
@@ -402,7 +450,7 @@ class ZipExportTest extends TestCase
         $page->save();
 
         $zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $pageData = $zip->data['page'];
 
         $this->assertStringContainsString('href="[[bsexport:attachment:' . $attachment->id . ']]?open=true"', $pageData['html']);
@@ -417,7 +465,7 @@ class ZipExportTest extends TestCase
         $page->save();
 
         $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip"));
-        $zip = $this->extractZipResponse($zipResp);
+        $zip = ZipTestHelper::extractFromZipResponse($zipResp);
         $pageData = $zip->data['chapter']['pages'][0];
 
         $this->assertStringContainsString("[Link to chapter]([[bsexport:chapter:{$chapter->id}]])", $pageData['markdown']);
@@ -444,30 +492,4 @@ class ZipExportTest extends TestCase
         }
         $this->get($page->getUrl("/export/zip"))->assertStatus(429);
     }
-
-    protected function extractZipResponse(TestResponse $response): ZipResultData
-    {
-        $zipData = $response->streamedContent();
-        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
-
-        file_put_contents($zipFile, $zipData);
-        $extractDir = tempnam(sys_get_temp_dir(), 'bstestextracted-');
-        if (file_exists($extractDir)) {
-            unlink($extractDir);
-        }
-        mkdir($extractDir);
-
-        $zip = new ZipArchive();
-        $zip->open($zipFile, ZipArchive::RDONLY);
-        $zip->extractTo($extractDir);
-
-        $dataJson = file_get_contents($extractDir . DIRECTORY_SEPARATOR . "data.json");
-        $data = json_decode($dataJson, true);
-
-        return new ZipResultData(
-            $zipFile,
-            $extractDir,
-            $data,
-        );
-    }
 }
diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php
index d3af6df76..2255e16c3 100644
--- a/tests/Exports/ZipImportRunnerTest.php
+++ b/tests/Exports/ZipImportRunnerTest.php
@@ -109,7 +109,7 @@ class ZipImportRunnerTest extends TestCase
 
         // Book checks
         $this->assertEquals('Import test', $book->name);
-        $this->assertFileExists(public_path($book->cover->path));
+        $this->assertFileExists(public_path($book->coverInfo()->getImage()->path));
         $this->assertCount(2, $book->tags);
         $this->assertEquals('Cat', $book->tags()->first()->value);
         $this->assertCount(2, $book->chapters);
@@ -393,4 +393,42 @@ class ZipImportRunnerTest extends TestCase
 
         ZipTestHelper::deleteZipForImport($import);
     }
+
+    public function test_drawing_references_are_updated_within_content()
+    {
+        $testImagePath = $this->files->testFilePath('test-image.png');
+        $parent = $this->entities->chapter();
+
+        $import = ZipTestHelper::importFromData([], [
+            'page' => [
+                'name' => 'Page A',
+                'html' => '<div drawio-diagram="1125"><img src="[[bsexport:image:1125]]"></div>',
+                'images' => [
+                    [
+                        'id' => 1125,
+                        'name' => 'Cat',
+                        'type' => 'drawio',
+                        'file' => 'my_drawing'
+                    ]
+                ],
+            ],
+        ], [
+            'my_drawing' => $testImagePath,
+        ]);
+
+        $this->asAdmin();
+        /** @var Page $page */
+        $page = $this->runner->run($import, $parent);
+
+        $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get();
+        $this->assertCount(1, $pageImages);
+        $this->assertEquals('drawio', $pageImages[0]->type);
+
+        $drawingId = $pageImages[0]->id;
+        $this->assertStringContainsString("drawio-diagram=\"{$drawingId}\"", $page->html);
+        $this->assertStringNotContainsString('[[bsexport:image:1125]]', $page->html);
+        $this->assertStringNotContainsString('drawio-diagram="1125"', $page->html);
+
+        ZipTestHelper::deleteZipForImport($import);
+    }
 }
diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php
index ad0e6b241..54e589aa3 100644
--- a/tests/Exports/ZipImportTest.php
+++ b/tests/Exports/ZipImportTest.php
@@ -76,7 +76,7 @@ class ZipImportTest extends TestCase
     {
         $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
         $zip = new ZipArchive();
-        $zip->open($zipFile, ZipArchive::CREATE);
+        $zip->open($zipFile, ZipArchive::OVERWRITE);
         $zip->addFromString('beans', 'cat');
         $zip->close();
 
diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php
index d830d8eb6..c5a7235a0 100644
--- a/tests/Exports/ZipTestHelper.php
+++ b/tests/Exports/ZipTestHelper.php
@@ -4,6 +4,7 @@ namespace Tests\Exports;
 
 use BookStack\Exports\Import;
 use Illuminate\Http\UploadedFile;
+use Illuminate\Testing\TestResponse;
 use ZipArchive;
 
 class ZipTestHelper
@@ -45,7 +46,7 @@ class ZipTestHelper
         $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
 
         $zip = new ZipArchive();
-        $zip->open($zipFile, ZipArchive::CREATE);
+        $zip->open($zipFile, ZipArchive::OVERWRITE);
         $zip->addFromString('data.json', json_encode($data));
 
         foreach ($files as $name => $file) {
@@ -56,4 +57,30 @@ class ZipTestHelper
 
         return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true);
     }
+
+    public static function extractFromZipResponse(TestResponse $response): ZipResultData
+    {
+        $zipData = $response->streamedContent();
+        $zipFile = tempnam(sys_get_temp_dir(), 'bstest-');
+
+        file_put_contents($zipFile, $zipData);
+        $extractDir = tempnam(sys_get_temp_dir(), 'bstestextracted-');
+        if (file_exists($extractDir)) {
+            unlink($extractDir);
+        }
+        mkdir($extractDir);
+
+        $zip = new ZipArchive();
+        $zip->open($zipFile, ZipArchive::RDONLY);
+        $zip->extractTo($extractDir);
+
+        $dataJson = file_get_contents($extractDir . DIRECTORY_SEPARATOR . "data.json");
+        $data = json_decode($dataJson, true);
+
+        return new ZipResultData(
+            $zipFile,
+            $extractDir,
+            $data,
+        );
+    }
 }
diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php
index 22e554f74..5163cef14 100644
--- a/tests/Helpers/EntityProvider.php
+++ b/tests/Helpers/EntityProvider.php
@@ -6,7 +6,6 @@ use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\HasCoverImage;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Repos\BookshelfRepo;
@@ -34,9 +33,9 @@ class EntityProvider
     ];
 
     /**
-     * Get an un-fetched page from the system.
+     * Get an unfetched page from the system.
      */
-    public function page(callable $queryFilter = null): Page
+    public function page(callable|null $queryFilter = null): Page
     {
         /** @var Page $page */
         $page = Page::query()->when($queryFilter, $queryFilter)->whereNotIn('id', $this->fetchCache['page'])->first();
@@ -51,7 +50,7 @@ class EntityProvider
 
     public function pageNotWithinChapter(): Page
     {
-        return $this->page(fn(Builder $query) => $query->where('chapter_id', '=', 0));
+        return $this->page(fn(Builder $query) => $query->whereNull('chapter_id'));
     }
 
     public function templatePage(): Page
@@ -64,9 +63,9 @@ class EntityProvider
     }
 
     /**
-     * Get an un-fetched chapter from the system.
+     * Get an unfetched chapter from the system.
      */
-    public function chapter(callable $queryFilter = null): Chapter
+    public function chapter(callable|null $queryFilter = null): Chapter
     {
         /** @var Chapter $chapter */
         $chapter = Chapter::query()->when($queryFilter, $queryFilter)->whereNotIn('id', $this->fetchCache['chapter'])->first();
@@ -80,9 +79,9 @@ class EntityProvider
     }
 
     /**
-     * Get an un-fetched book from the system.
+     * Get an unfetched book from the system.
      */
-    public function book(callable $queryFilter = null): Book
+    public function book(callable|null $queryFilter = null): Book
     {
         /** @var Book $book */
         $book = Book::query()->when($queryFilter, $queryFilter)->whereNotIn('id', $this->fetchCache['book'])->first();
@@ -101,9 +100,9 @@ class EntityProvider
     }
 
     /**
-     * Get an un-fetched shelf from the system.
+     * Get an unfetched shelf from the system.
      */
-    public function shelf(callable $queryFilter = null): Bookshelf
+    public function shelf(callable|null $queryFilter = null): Bookshelf
     {
         /** @var Bookshelf $shelf */
         $shelf = Bookshelf::query()->when($queryFilter, $queryFilter)->whereNotIn('id', $this->fetchCache['bookshelf'])->first();
diff --git a/tests/Helpers/FileProvider.php b/tests/Helpers/FileProvider.php
index 442e036ff..a455e0fb4 100644
--- a/tests/Helpers/FileProvider.php
+++ b/tests/Helpers/FileProvider.php
@@ -60,6 +60,14 @@ class FileProvider
         return file_get_contents($this->testFilePath('test-image.png'));
     }
 
+    /**
+     * Get raw data for a Jpeg image test file.
+     */
+    public function jpegImageData(): string
+    {
+        return file_get_contents($this->testFilePath('test-image.jpg'));
+    }
+
     /**
      * Get the expected relative path for an uploaded image of the given type and filename.
      */
diff --git a/tests/Helpers/PermissionsProvider.php b/tests/Helpers/PermissionsProvider.php
index cb036fe97..c9ae30919 100644
--- a/tests/Helpers/PermissionsProvider.php
+++ b/tests/Helpers/PermissionsProvider.php
@@ -5,6 +5,7 @@ namespace Tests\Helpers;
 use BookStack\Entities\Models\Entity;
 use BookStack\Permissions\Models\EntityPermission;
 use BookStack\Permissions\Models\RolePermission;
+use BookStack\Permissions\Permission;
 use BookStack\Settings\SettingService;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
@@ -139,8 +140,8 @@ class PermissionsProvider
     protected function actionListToEntityPermissionData(array $actionList, int $roleId = 0): array
     {
         $permissionData = ['role_id' => $roleId];
-        foreach (EntityPermission::PERMISSIONS as $possibleAction) {
-            $permissionData[$possibleAction] = in_array($possibleAction, $actionList);
+        foreach (Permission::genericForEntity() as $permission) {
+            $permissionData[$permission->value] = in_array($permission->value, $actionList);
         }
 
         return $permissionData;
diff --git a/tests/Meta/OpenGraphTest.php b/tests/Meta/OpenGraphTest.php
index 96e622da0..b35716359 100644
--- a/tests/Meta/OpenGraphTest.php
+++ b/tests/Meta/OpenGraphTest.php
@@ -49,7 +49,7 @@ class OpenGraphTest extends TestCase
         $resp = $this->asEditor()->get($book->getUrl());
         $tags = $this->getOpenGraphTags($resp);
 
-        $this->assertEquals($book->getBookCover(), $tags['image']);
+        $this->assertEquals($book->coverInfo()->getUrl(), $tags['image']);
     }
 
     public function test_shelf_tags()
@@ -69,7 +69,7 @@ class OpenGraphTest extends TestCase
         $resp = $this->asEditor()->get($shelf->getUrl());
         $tags = $this->getOpenGraphTags($resp);
 
-        $this->assertEquals($shelf->getBookCover(), $tags['image']);
+        $this->assertEquals($shelf->coverInfo()->getUrl(), $tags['image']);
     }
 
     /**
diff --git a/tests/Meta/OpensearchTest.php b/tests/Meta/OpensearchTest.php
index 38e158716..0c9ec9f44 100644
--- a/tests/Meta/OpensearchTest.php
+++ b/tests/Meta/OpensearchTest.php
@@ -15,6 +15,7 @@ class OpensearchTest extends TestCase
 
         $resp = $this->get('/opensearch.xml');
         $resp->assertOk();
+        $resp->assertSee('<?xml version="1.0" encoding="UTF-8"?>' . "\n", false);
 
         $html = $this->withHtml($resp);
 
diff --git a/tests/Permissions/EntityOwnerChangeTest.php b/tests/Permissions/EntityOwnerChangeTest.php
index f00254922..fd3f27972 100644
--- a/tests/Permissions/EntityOwnerChangeTest.php
+++ b/tests/Permissions/EntityOwnerChangeTest.php
@@ -13,7 +13,7 @@ class EntityOwnerChangeTest extends TestCase
         $user = User::query()->where('id', '!=', $page->owned_by)->first();
 
         $this->asAdmin()->put($page->getUrl('permissions'), ['owned_by' => $user->id]);
-        $this->assertDatabaseHas('pages', ['owned_by' => $user->id, 'id' => $page->id]);
+        $this->assertDatabaseHasEntityData('page', ['owned_by' => $user->id, 'id' => $page->id]);
     }
 
     public function test_changing_chapter_owner()
@@ -22,7 +22,7 @@ class EntityOwnerChangeTest extends TestCase
         $user = User::query()->where('id', '!=', $chapter->owned_by)->first();
 
         $this->asAdmin()->put($chapter->getUrl('permissions'), ['owned_by' => $user->id]);
-        $this->assertDatabaseHas('chapters', ['owned_by' => $user->id, 'id' => $chapter->id]);
+        $this->assertDatabaseHasEntityData('chapter', ['owned_by' => $user->id, 'id' => $chapter->id]);
     }
 
     public function test_changing_book_owner()
@@ -31,7 +31,7 @@ class EntityOwnerChangeTest extends TestCase
         $user = User::query()->where('id', '!=', $book->owned_by)->first();
 
         $this->asAdmin()->put($book->getUrl('permissions'), ['owned_by' => $user->id]);
-        $this->assertDatabaseHas('books', ['owned_by' => $user->id, 'id' => $book->id]);
+        $this->assertDatabaseHasEntityData('book', ['owned_by' => $user->id, 'id' => $book->id]);
     }
 
     public function test_changing_shelf_owner()
@@ -40,6 +40,6 @@ class EntityOwnerChangeTest extends TestCase
         $user = User::query()->where('id', '!=', $shelf->owned_by)->first();
 
         $this->asAdmin()->put($shelf->getUrl('permissions'), ['owned_by' => $user->id]);
-        $this->assertDatabaseHas('bookshelves', ['owned_by' => $user->id, 'id' => $shelf->id]);
+        $this->assertDatabaseHasEntityData('bookshelf', ['owned_by' => $user->id, 'id' => $shelf->id]);
     }
 }
diff --git a/tests/Permissions/EntityPermissionsTest.php b/tests/Permissions/EntityPermissionsTest.php
index 6ea0257b8..d399e0c34 100644
--- a/tests/Permissions/EntityPermissionsTest.php
+++ b/tests/Permissions/EntityPermissionsTest.php
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
+use BookStack\Permissions\Permission;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
 use Exception;
@@ -25,7 +26,7 @@ class EntityPermissionsTest extends TestCase
         $this->viewer = $this->users->viewer();
     }
 
-    protected function setRestrictionsForTestRoles(Entity $entity, array $actions = [])
+    protected function setRestrictionsForTestRoles(Entity $entity, array $actions = []): void
     {
         $roles = [
             $this->user->roles->first(),
@@ -184,7 +185,7 @@ class EntityPermissionsTest extends TestCase
 
         $this->get($bookUrl . '/edit')->assertRedirect('/');
         $this->get('/')->assertSee('You do not have permission');
-        $this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get($bookPage->getUrl() . '/edit')->assertRedirect($bookPage->getUrl());
         $this->get('/')->assertSee('You do not have permission');
         $this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
         $this->get('/')->assertSee('You do not have permission');
@@ -282,7 +283,7 @@ class EntityPermissionsTest extends TestCase
 
         $this->get($chapterUrl . '/edit')->assertRedirect('/');
         $this->get('/')->assertSee('You do not have permission');
-        $this->get($chapterPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get($chapterPage->getUrl() . '/edit')->assertRedirect($chapterPage->getUrl());
         $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'update']);
@@ -341,7 +342,7 @@ class EntityPermissionsTest extends TestCase
 
         $this->setRestrictionsForTestRoles($page, ['view', 'delete']);
 
-        $this->get($pageUrl . '/edit')->assertRedirect('/');
+        $this->get($pageUrl . '/edit')->assertRedirect($pageUrl);
         $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($page, ['view', 'update']);
@@ -565,7 +566,7 @@ class EntityPermissionsTest extends TestCase
 
         $this->get($bookUrl . '/edit')->assertRedirect('/');
         $this->get('/')->assertSee('You do not have permission');
-        $this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get($bookPage->getUrl() . '/edit')->assertRedirect($bookPage->getUrl());
         $this->get('/')->assertSee('You do not have permission');
         $this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
         $this->get('/')->assertSee('You do not have permission');
@@ -628,10 +629,8 @@ class EntityPermissionsTest extends TestCase
 
     public function test_book_sort_view_permission()
     {
-        /** @var Book $firstBook */
-        $firstBook = Book::query()->first();
-        /** @var Book $secondBook */
-        $secondBook = Book::query()->find(2);
+        $firstBook = $this->entities->book();
+        $secondBook = $this->entities->book();
 
         $this->setRestrictionsForTestRoles($firstBook, ['view', 'update']);
         $this->setRestrictionsForTestRoles($secondBook, ['view']);
@@ -676,7 +675,7 @@ class EntityPermissionsTest extends TestCase
         $this->permissions->setEntityPermissions($book, ['update'], [$viewerRole], false);
         $this->permissions->setEntityPermissions($chapter, [], [$viewerRole], true);
 
-        $this->assertFalse(userCan('chapter-update', $chapter));
+        $this->assertFalse(userCan(Permission::ChapterUpdate, $chapter));
     }
 
     public function test_access_to_item_allowed_if_inheritance_active_and_permission_prevented_via_role_but_allowed_via_parent()
@@ -692,7 +691,7 @@ class EntityPermissionsTest extends TestCase
         $this->permissions->setEntityPermissions($chapter, [], [$viewerRole], true);
 
         $this->actingAs($user);
-        $this->assertTrue(userCan('chapter-update', $chapter));
+        $this->assertTrue(userCan(Permission::ChapterUpdate, $chapter));
     }
 
     public function test_book_permissions_can_be_generated_without_error_if_child_chapter_is_in_recycle_bin()
diff --git a/tests/Permissions/RolePermissionsTest.php b/tests/Permissions/RolePermissionsTest.php
index d3146bd47..97cce6817 100644
--- a/tests/Permissions/RolePermissionsTest.php
+++ b/tests/Permissions/RolePermissionsTest.php
@@ -2,7 +2,6 @@
 
 namespace Tests\Permissions;
 
-use BookStack\Activity\ActivityType;
 use BookStack\Activity\Models\Comment;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
@@ -10,7 +9,6 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use BookStack\Uploads\Image;
-use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
 use Illuminate\Testing\TestResponse;
 use Tests\TestCase;
@@ -152,10 +150,14 @@ class RolePermissionsTest extends TestCase
     /**
      * Check a standard entity access permission.
      */
-    private function checkAccessPermission(string $permission, array $accessUrls = [], array $visibles = [])
-    {
+    private function checkAccessPermission(
+        string $permission,
+        array $accessUrls = [],
+        array $visibles = [],
+        string $expectedRedirectUri = '/',
+    ) {
         foreach ($accessUrls as $url) {
-            $this->actingAs($this->user)->get($url)->assertRedirect('/');
+            $this->actingAs($this->user)->get($url)->assertRedirect($expectedRedirectUri);
         }
 
         foreach ($visibles as $url => $text) {
@@ -535,11 +537,11 @@ class RolePermissionsTest extends TestCase
             $ownPage->getUrl() . '/edit',
         ], [
             $ownPage->getUrl() => 'Edit',
-        ]);
+        ], $ownPage->getUrl());
 
         $resp = $this->get($otherPage->getUrl());
         $this->withHtml($resp)->assertElementNotContains('.action-buttons', 'Edit');
-        $this->get($otherPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get($otherPage->getUrl() . '/edit')->assertRedirect($otherPage->getUrl());
     }
 
     public function test_page_edit_all_permission()
@@ -550,7 +552,7 @@ class RolePermissionsTest extends TestCase
             $otherPage->getUrl('/edit'),
         ], [
             $otherPage->getUrl() => 'Edit',
-        ]);
+        ], $otherPage->getUrl());
     }
 
     public function test_page_delete_own_permission()
diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php
index 76745aaac..e6fc7a6a3 100644
--- a/tests/PublicActionTest.php
+++ b/tests/PublicActionTest.php
@@ -104,7 +104,7 @@ class PublicActionTest extends TestCase
         $resp->assertRedirect($chapter->book->getUrl('/page/my-guest-page/edit'));
 
         $user = $this->users->guest();
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'name'       => 'My guest page',
             'chapter_id' => $chapter->id,
             'created_by' => $user->id,
diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php
index f8698d028..389f164a9 100644
--- a/tests/References/ReferencesTest.php
+++ b/tests/References/ReferencesTest.php
@@ -259,7 +259,7 @@ class ReferencesTest extends TestCase
         }
 
         $oldUrl = $shelf->getUrl();
-        $this->put($shelf->getUrl(), ['name' => 'My updated shelf link']);
+        $this->put($shelf->getUrl(), ['name' => 'My updated shelf link'])->assertRedirect();
         $shelf->refresh();
         $this->assertNotEquals($oldUrl, $shelf->getUrl());
 
diff --git a/tests/Search/EntitySearchTest.php b/tests/Search/EntitySearchTest.php
index d3d859986..8501b65c4 100644
--- a/tests/Search/EntitySearchTest.php
+++ b/tests/Search/EntitySearchTest.php
@@ -27,6 +27,12 @@ class EntitySearchTest extends TestCase
         $search->assertSeeText($shelf->name, true);
     }
 
+    public function test_search_shows_pagination()
+    {
+        $search = $this->asEditor()->get('/search?term=a');
+        $this->withHtml($search)->assertLinkExists('/search?term=a&page=2', '2');
+    }
+
     public function test_invalid_page_search()
     {
         $resp = $this->asEditor()->get('/search?term=' . urlencode('<p>test</p>'));
@@ -372,6 +378,21 @@ class EntitySearchTest extends TestCase
         $search->assertSee('<strong>На</strong> <strong>мен</strong> <strong>ми</strong> <strong>трябва</strong> <strong>нещо</strong> <strong>добро</strong> test', false);
     }
 
+    public function test_match_highlighting_is_efficient_with_large_frequency_in_content()
+    {
+        $content = str_repeat('superbeans ', 10000);
+        $this->entities->newPage([
+            'name' => 'Test Page',
+            'html' => "<p>{$content}</p>",
+        ]);
+
+        $time = microtime(true);
+        $resp = $this->asEditor()->get('/search?term=' . urlencode('superbeans'));
+        $this->assertLessThan(0.5, microtime(true) - $time);
+
+        $resp->assertSee('<strong>superbeans</strong>', false);
+    }
+
     public function test_html_entities_in_item_details_remains_escaped_in_search_results()
     {
         $this->entities->newPage(['name' => 'My <cool> TestPageContent', 'html' => '<p>My supercool &lt;great&gt; TestPageContent page</p>']);
diff --git a/tests/Search/SearchIndexingTest.php b/tests/Search/SearchIndexingTest.php
index 64779dec6..d2bbb2905 100644
--- a/tests/Search/SearchIndexingTest.php
+++ b/tests/Search/SearchIndexingTest.php
@@ -106,4 +106,14 @@ class SearchIndexingTest extends TestCase
             $this->assertNull($scoreByTerm->get($term), "Failed asserting that \"$term\" is not indexed");
         }
     }
+
+    public function test_non_breaking_spaces_handled_as_spaces()
+    {
+        $page = $this->entities->newPage(['html' => '<p>a&nbsp;tigerbadger is a dangerous&nbsp;animal</p>']);
+
+        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
+        $this->assertNotNull($scoreByTerm->get('tigerbadger'));
+        $this->assertNotNull($scoreByTerm->get('dangerous'));
+        $this->assertNotNull($scoreByTerm->get('animal'));
+    }
 }
diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php
index 5d354e553..fe98e3208 100644
--- a/tests/SecurityHeaderTest.php
+++ b/tests/SecurityHeaderTest.php
@@ -17,7 +17,7 @@ class SecurityHeaderTest extends TestCase
 
     public function test_cookies_samesite_none_when_iframe_hosts_set()
     {
-        $this->runWithEnv('ALLOWED_IFRAME_HOSTS', 'http://example.com', function () {
+        $this->runWithEnv(['ALLOWED_IFRAME_HOSTS' => 'http://example.com'], function () {
             $resp = $this->get('/');
             foreach ($resp->headers->getCookies() as $cookie) {
                 $this->assertEquals('none', $cookie->getSameSite());
@@ -27,14 +27,14 @@ class SecurityHeaderTest extends TestCase
 
     public function test_secure_cookies_controlled_by_app_url()
     {
-        $this->runWithEnv('APP_URL', 'http://example.com', function () {
+        $this->runWithEnv(['APP_URL' => 'http://example.com'], function () {
             $resp = $this->get('/');
             foreach ($resp->headers->getCookies() as $cookie) {
                 $this->assertFalse($cookie->isSecure());
             }
         });
 
-        $this->runWithEnv('APP_URL', 'https://example.com', function () {
+        $this->runWithEnv(['APP_URL' => 'https://example.com'], function () {
             $resp = $this->get('/');
             foreach ($resp->headers->getCookies() as $cookie) {
                 $this->assertTrue($cookie->isSecure());
@@ -52,7 +52,7 @@ class SecurityHeaderTest extends TestCase
 
     public function test_iframe_csp_includes_extra_hosts_if_configured()
     {
-        $this->runWithEnv('ALLOWED_IFRAME_HOSTS', 'https://a.example.com https://b.example.com', function () {
+        $this->runWithEnv(['ALLOWED_IFRAME_HOSTS' => 'https://a.example.com https://b.example.com'], function () {
             $resp = $this->get('/');
             $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
 
diff --git a/tests/Settings/RecycleBinTest.php b/tests/Settings/RecycleBinTest.php
index 33284b4b3..c17cfed97 100644
--- a/tests/Settings/RecycleBinTest.php
+++ b/tests/Settings/RecycleBinTest.php
@@ -3,6 +3,7 @@
 namespace Tests\Settings;
 
 use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Deletion;
 use BookStack\Entities\Models\Page;
 use Illuminate\Support\Carbon;
@@ -82,10 +83,12 @@ class RecycleBinTest extends TestCase
         $emptyReq->assertRedirect('/settings/recycle-bin');
 
         $this->assertTrue(Deletion::query()->count() === 0);
-        $this->assertDatabaseMissing('books', ['id' => $book->id]);
-        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
-        $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
-        $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']);
+        $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']);
+        $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']);
+        $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']);
+        $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']);
 
         $itemCount = 2 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
@@ -95,18 +98,18 @@ class RecycleBinTest extends TestCase
     public function test_entity_restore()
     {
         $book = $this->entities->bookHasChaptersAndPages();
-        $this->asEditor()->delete($book->getUrl());
+        $this->asEditor()->delete($book->getUrl())->assertRedirect();
         $deletion = Deletion::query()->firstOrFail();
 
-        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
-        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+        $this->assertEquals($book->pages->count(), Page::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), Chapter::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
 
         $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore");
         $restoreReq->assertRedirect('/settings/recycle-bin');
         $this->assertTrue(Deletion::query()->count() === 0);
 
-        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
-        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+        $this->assertEquals($book->pages->count(), Page::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), Chapter::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
 
         $itemCount = 1 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
@@ -123,9 +126,12 @@ class RecycleBinTest extends TestCase
         $deleteReq->assertRedirect('/settings/recycle-bin');
         $this->assertTrue(Deletion::query()->count() === 0);
 
-        $this->assertDatabaseMissing('books', ['id' => $book->id]);
-        $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
-        $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']);
+        $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']);
+        $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']);
+        $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']);
+        $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']);
 
         $itemCount = 1 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
@@ -173,6 +179,34 @@ class RecycleBinTest extends TestCase
         ]);
     }
 
+    public function test_permanent_book_delete_removes_shelf_relation_data()
+    {
+        $book = $this->entities->book();
+        $shelf = $this->entities->shelf();
+        $shelf->books()->attach($book);
+        $this->assertDatabaseHas('bookshelves_books', ['book_id' => $book->id]);
+
+        $this->asEditor()->delete($book->getUrl());
+        $deletion = $book->deletions()->firstOrFail();
+        $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect();
+
+        $this->assertDatabaseMissing('bookshelves_books', ['book_id' => $book->id]);
+    }
+
+    public function test_permanent_shelf_delete_removes_book_relation_data()
+    {
+        $book = $this->entities->book();
+        $shelf = $this->entities->shelf();
+        $shelf->books()->attach($book);
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id]);
+
+        $this->asEditor()->delete($shelf->getUrl());
+        $deletion = $shelf->deletions()->firstOrFail();
+        $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect();
+
+        $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
+    }
+
     public function test_auto_clear_functionality_works()
     {
         config()->set('app.recycle_bin_lifetime', 5);
@@ -180,14 +214,14 @@ class RecycleBinTest extends TestCase
         $otherPage = $this->entities->page();
 
         $this->asEditor()->delete($page->getUrl());
-        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+        $this->assertDatabaseHasEntityData('page', ['id' => $page->id]);
         $this->assertEquals(1, Deletion::query()->count());
 
         Carbon::setTestNow(Carbon::now()->addDays(6));
         $this->asEditor()->delete($otherPage->getUrl());
         $this->assertEquals(1, Deletion::query()->count());
 
-        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
     }
 
     public function test_auto_clear_functionality_with_negative_time_keeps_forever()
@@ -203,7 +237,7 @@ class RecycleBinTest extends TestCase
         $this->asEditor()->delete($otherPage->getUrl());
         $this->assertEquals(2, Deletion::query()->count());
 
-        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+        $this->assertDatabaseHasEntityData('page', ['id' => $page->id]);
     }
 
     public function test_auto_clear_functionality_with_zero_time_deletes_instantly()
@@ -212,7 +246,7 @@ class RecycleBinTest extends TestCase
         $page = $this->entities->page();
 
         $this->asEditor()->delete($page->getUrl());
-        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
         $this->assertEquals(0, Deletion::query()->count());
     }
 
diff --git a/tests/Settings/SettingsTest.php b/tests/Settings/SettingsTest.php
index 9d45706e7..a3e65b483 100644
--- a/tests/Settings/SettingsTest.php
+++ b/tests/Settings/SettingsTest.php
@@ -101,4 +101,17 @@ class SettingsTest extends TestCase
             file_get_contents(public_path('favicon.ico')),
         );
     }
+
+    public function test_both_light_and_dark_colors_are_used_in_the_base_view()
+    {
+        // To allow for dynamic color changes on the front-end where desired.
+        $this->setSettings(['page-color' => 'superlightblue', 'page-color-dark' => 'superdarkblue']);
+
+        $resp = $this->get('/login');
+
+        $resp->assertSee(':root {');
+        $resp->assertSee('superlightblue');
+        $resp->assertSee(':root.dark-mode {');
+        $resp->assertSee('superdarkblue');
+    }
 }
diff --git a/tests/Sorting/BookSortTest.php b/tests/Sorting/BookSortTest.php
index 4737ec231..7f31f9c27 100644
--- a/tests/Sorting/BookSortTest.php
+++ b/tests/Sorting/BookSortTest.php
@@ -66,7 +66,7 @@ class BookSortTest extends TestCase
         $sortResp = $this->asEditor()->put($newBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)]);
         $sortResp->assertRedirect($newBook->getUrl());
         $sortResp->assertStatus(302);
-        $this->assertDatabaseHas('chapters', [
+        $this->assertDatabaseHasEntityData('chapter', [
             'id'       => $chapterToMove->id,
             'book_id'  => $newBook->id,
             'priority' => 0,
@@ -93,7 +93,7 @@ class BookSortTest extends TestCase
         ];
         $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -114,7 +114,7 @@ class BookSortTest extends TestCase
         ];
         $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -136,7 +136,7 @@ class BookSortTest extends TestCase
         ];
         $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -158,7 +158,7 @@ class BookSortTest extends TestCase
         ];
         $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -180,7 +180,7 @@ class BookSortTest extends TestCase
         ];
         $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -202,7 +202,7 @@ class BookSortTest extends TestCase
         ];
         $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
         ]);
     }
@@ -211,7 +211,7 @@ class BookSortTest extends TestCase
     {
         $book = $this->entities->bookHasChaptersAndPages();
         $chapter = $book->chapters()->first();
-        \DB::table('chapters')->where('id', '=', $chapter->id)->update([
+        Chapter::query()->where('id', '=', $chapter->id)->update([
             'priority' => 10001,
             'updated_at' => \Carbon\Carbon::now()->subYear(5),
         ]);
@@ -299,7 +299,7 @@ class BookSortTest extends TestCase
         $book = $this->entities->bookHasChaptersAndPages();
         $book->chapters()->forceDelete();
         /** @var Page[] $pages */
-        $pages = $book->pages()->where('chapter_id', '=', 0)->take(2)->get();
+        $pages = $book->pages()->whereNull('chapter_id')->take(2)->get();
         $book->pages()->whereNotIn('id', $pages->pluck('id'))->delete();
 
         $resp = $this->asEditor()->get($book->getUrl());
diff --git a/tests/Sorting/MoveTest.php b/tests/Sorting/MoveTest.php
index 606b23c68..5a341026b 100644
--- a/tests/Sorting/MoveTest.php
+++ b/tests/Sorting/MoveTest.php
@@ -20,7 +20,7 @@ class MoveTest extends TestCase
 
         $movePageResp = $this->put($page->getUrl('/move'), [
             'entity_selection' => 'book:' . $newBook->id,
-        ]);
+        ])->assertRedirect();
         $page->refresh();
 
         $movePageResp->assertRedirect($page->getUrl());
diff --git a/tests/Sorting/SortRuleTest.php b/tests/Sorting/SortRuleTest.php
index 4a9d3a7b3..a6be9beef 100644
--- a/tests/Sorting/SortRuleTest.php
+++ b/tests/Sorting/SortRuleTest.php
@@ -142,7 +142,7 @@ class SortRuleTest extends TestCase
         $resp = $this->delete("settings/sorting/rules/{$rule->id}", ['confirm' => 'true']);
         $resp->assertRedirect('/settings/sorting');
         $this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]);
-        $this->assertDatabaseMissing('books', ['sort_rule_id' => $rule->id]);
+        $this->assertDatabaseMissing('entity_container_data', ['sort_rule_id' => $rule->id]);
     }
 
     public function test_page_create_triggers_book_sort()
@@ -159,7 +159,7 @@ class SortRuleTest extends TestCase
         ]);
         $resp->assertOk();
 
-        $this->assertDatabaseHas('pages', [
+        $this->assertDatabaseHasEntityData('page', [
             'book_id' => $book->id,
             'name' => '1111 page',
             'priority' => $book->chapters()->count() + 1,
@@ -217,7 +217,7 @@ class SortRuleTest extends TestCase
         }
 
         foreach ($namesToAdd as $index => $name) {
-            $this->assertDatabaseHas('pages', [
+            $this->assertDatabaseHasEntityData('page', [
                 'book_id' => $book->id,
                 'name' => $name,
                 'priority' => $index + 1,
@@ -251,7 +251,7 @@ class SortRuleTest extends TestCase
         }
 
         foreach ($namesToAdd as $index => $name) {
-            $this->assertDatabaseHas('pages', [
+            $this->assertDatabaseHasEntityData('page', [
                 'book_id' => $book->id,
                 'name' => $name,
                 'priority' => $index + 1,
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 0fb899ea9..f69f20d4c 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -6,7 +6,7 @@ use BookStack\Entities\Models\Entity;
 use BookStack\Http\HttpClientHistory;
 use BookStack\Http\HttpRequestService;
 use BookStack\Settings\SettingService;
-use BookStack\Users\Models\User;
+use Exception;
 use Illuminate\Contracts\Console\Kernel;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
@@ -15,6 +15,7 @@ use Illuminate\Support\Env;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Testing\Assert as PHPUnit;
+use Illuminate\Testing\Constraints\HasInDatabase;
 use Monolog\Handler\TestHandler;
 use Monolog\Logger;
 use Ssddanbrown\AssertHtml\TestsHtml;
@@ -118,15 +119,18 @@ abstract class TestCase extends BaseTestCase
      * Database config is juggled so the value can be restored when
      * parallel testing are used, where multiple databases exist.
      */
-    protected function runWithEnv(string $name, $value, callable $callback, bool $handleDatabase = true)
+    protected function runWithEnv(array $valuesByKey, callable $callback, bool $handleDatabase = true): void
     {
         Env::disablePutenv();
-        $originalVal = $_SERVER[$name] ?? null;
-
-        if (is_null($value)) {
-            unset($_SERVER[$name]);
-        } else {
-            $_SERVER[$name] = $value;
+        $originals = [];
+        foreach ($valuesByKey as $key => $value) {
+            $originals[$key] = $_SERVER[$key] ?? null;
+
+            if (is_null($value)) {
+                unset($_SERVER[$key]);
+            } else {
+                $_SERVER[$key] = $value;
+            }
         }
 
         $database = config('database.connections.mysql_testing.database');
@@ -144,10 +148,12 @@ abstract class TestCase extends BaseTestCase
             DB::rollBack();
         }
 
-        if (is_null($originalVal)) {
-            unset($_SERVER[$name]);
-        } else {
-            $_SERVER[$name] = $originalVal;
+        foreach ($originals as $key => $value) {
+            if (is_null($value)) {
+                unset($_SERVER[$key]);
+            } else {
+                $_SERVER[$key] = $value;
+            }
         }
     }
 
@@ -193,7 +199,7 @@ abstract class TestCase extends BaseTestCase
     {
         if ($response->status() === 403 && $response instanceof JsonResponse) {
             $errMessage = $response->getData(true)['error']['message'] ?? '';
-            return $errMessage === 'You do not have permission to perform the requested action.';
+            return str_contains($errMessage, 'do not have permission');
         }
 
         return $response->status() === 302
@@ -262,4 +268,42 @@ abstract class TestCase extends BaseTestCase
 
         $this->assertDatabaseHas('activities', $detailsToCheck);
     }
+
+    /**
+     * Assert the database has the given data for an entity type.
+     */
+    protected function assertDatabaseHasEntityData(string $type, array $data = []): self
+    {
+        $entityFields = array_intersect_key($data, array_flip(Entity::$commonFields));
+        $extraFields = array_diff_key($data, $entityFields);
+        $extraTable = $type === 'page' ? 'entity_page_data' : 'entity_container_data';
+        $entityFields['type'] = $type;
+
+        $this->assertThat(
+            $this->getTable('entities'),
+            new HasInDatabase($this->getConnection(null, 'entities'), $entityFields)
+        );
+
+        if (!empty($extraFields)) {
+            $id = $entityFields['id'] ?? DB::table($this->getTable('entities'))
+                ->where($entityFields)->orderByDesc('id')->first()->id ?? null;
+            if (is_null($id)) {
+                throw new Exception('Failed to find entity id for asserting database data');
+            }
+
+            if ($type !== 'page') {
+                $extraFields['entity_id'] = $id;
+                $extraFields['entity_type'] = $type;
+            } else {
+                $extraFields['page_id'] = $id;
+            }
+
+            $this->assertThat(
+                $this->getTable($extraTable),
+                new HasInDatabase($this->getConnection(null, $extraTable), $extraFields)
+            );
+        }
+
+        return $this;
+    }
 }
diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php
index b3c85d8f7..4dff38418 100644
--- a/tests/ThemeTest.php
+++ b/tests/ThemeTest.php
@@ -46,7 +46,7 @@ class ThemeTest extends TestCase
             $functionsFile = theme_path('functions.php');
             app()->alias('cat', 'dog');
             file_put_contents($functionsFile, "<?php\nTheme::listen(\BookStack\Theming\ThemeEvents::APP_BOOT, function(\$app) { \$app->alias('cat', 'dog');});");
-            $this->runWithEnv('APP_THEME', $themeFolder, function () {
+            $this->runWithEnv(['APP_THEME' => $themeFolder], function () {
                 $this->assertEquals('cat', $this->app->getAlias('dog'));
             });
         });
@@ -61,7 +61,7 @@ class ThemeTest extends TestCase
             $this->expectException(ThemeException::class);
             $this->expectExceptionMessageMatches('/Failed loading theme functions file at ".*?" with error: Class "BookStack\\\\Biscuits" not found/');
 
-            $this->runWithEnv('APP_THEME', $themeFolder, fn() => null);
+            $this->runWithEnv(['APP_THEME' => $themeFolder], fn() => null);
         });
     }
 
@@ -504,7 +504,7 @@ END;
         $this->beforeApplicationDestroyed(fn() => File::deleteDirectory($themeFolderPath));
 
         // Run provided callback with theme env option set
-        $this->runWithEnv('APP_THEME', $themeFolderName, function () use ($callback, $themeFolderName) {
+        $this->runWithEnv(['APP_THEME' => $themeFolderName], function () use ($callback, $themeFolderName) {
             call_user_func($callback, $themeFolderName);
         });
     }
diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php
index 2883fddb8..7795a861a 100644
--- a/tests/Unit/ConfigTest.php
+++ b/tests/Unit/ConfigTest.php
@@ -16,7 +16,7 @@ class ConfigTest extends TestCase
 {
     public function test_filesystem_images_falls_back_to_storage_type_var()
     {
-        $this->runWithEnv('STORAGE_TYPE', 'local_secure', function () {
+        $this->runWithEnv(['STORAGE_TYPE' => 'local_secure'], function () {
             $this->checkEnvConfigResult('STORAGE_IMAGE_TYPE', 's3', 'filesystems.images', 's3');
             $this->checkEnvConfigResult('STORAGE_IMAGE_TYPE', null, 'filesystems.images', 'local_secure');
         });
@@ -24,7 +24,7 @@ class ConfigTest extends TestCase
 
     public function test_filesystem_attachments_falls_back_to_storage_type_var()
     {
-        $this->runWithEnv('STORAGE_TYPE', 'local_secure', function () {
+        $this->runWithEnv(['STORAGE_TYPE' => 'local_secure'], function () {
             $this->checkEnvConfigResult('STORAGE_ATTACHMENT_TYPE', 's3', 'filesystems.attachments', 's3');
             $this->checkEnvConfigResult('STORAGE_ATTACHMENT_TYPE', null, 'filesystems.attachments', 'local_secure');
         });
@@ -114,7 +114,7 @@ class ConfigTest extends TestCase
         $this->assertEmpty($getStreamOptions());
 
 
-        $this->runWithEnv('MAIL_VERIFY_SSL', 'false', function () use ($getStreamOptions) {
+        $this->runWithEnv(['MAIL_VERIFY_SSL' => 'false'], function () use ($getStreamOptions) {
             $options = $getStreamOptions();
             $this->assertArrayHasKey('ssl', $options);
             $this->assertFalse($options['ssl']['verify_peer']);
@@ -124,9 +124,9 @@ class ConfigTest extends TestCase
 
     public function test_non_null_mail_encryption_options_enforce_smtp_scheme()
     {
-        $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'tls', 'mail.mailers.smtp.tls_required', true);
-        $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'ssl', 'mail.mailers.smtp.tls_required', true);
-        $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'null', 'mail.mailers.smtp.tls_required', false);
+        $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'tls', 'mail.mailers.smtp.require_tls', true);
+        $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'ssl', 'mail.mailers.smtp.require_tls', true);
+        $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'null', 'mail.mailers.smtp.require_tls', false);
     }
 
     public function test_smtp_scheme_and_certain_port_forces_tls_usage()
@@ -135,29 +135,18 @@ class ConfigTest extends TestCase
             /** @var EsmtpTransport $transport */
             $transport = Mail::mailer('smtp')->getSymfonyTransport();
             Mail::purge('smtp');
-            return $transport->getTlsRequirement();
+            return $transport->isTlsRequired();
         };
 
-        config()->set([
-            'mail.mailers.smtp.tls_required' => null,
-            'mail.mailers.smtp.port' => 587,
-        ]);
-
-        $this->assertFalse($isMailTlsRequired());
-
-        config()->set([
-            'mail.mailers.smtp.tls_required' => 'tls',
-            'mail.mailers.smtp.port' => 587,
-        ]);
-
-        $this->assertTrue($isMailTlsRequired());
-
-        config()->set([
-            'mail.mailers.smtp.tls_required' => null,
-            'mail.mailers.smtp.port' => 465,
-        ]);
+        $runTest = function (string $tlsOption, int $port, bool $expectedResult) use ($isMailTlsRequired) {
+            $this->runWithEnv(['MAIL_ENCRYPTION' => $tlsOption, 'MAIL_PORT' => $port], function () use ($isMailTlsRequired, $port, $expectedResult) {
+                $this->assertEquals($expectedResult, $isMailTlsRequired());
+            });
+        };
 
-        $this->assertTrue($isMailTlsRequired());
+        $runTest('null', 587, false);
+        $runTest('tls', 587, true);
+        $runTest('null', 465, true);
     }
 
     public function test_mysql_host_parsed_as_expected()
@@ -174,7 +163,7 @@ class ConfigTest extends TestCase
         ];
 
         foreach ($cases as $host => [$expectedHost, $expectedPort]) {
-            $this->runWithEnv("DB_HOST", $host, function () use ($expectedHost, $expectedPort) {
+            $this->runWithEnv(["DB_HOST" => $host], function () use ($expectedHost, $expectedPort) {
                 $this->assertEquals($expectedHost, config("database.connections.mysql.host"));
                 $this->assertEquals($expectedPort, config("database.connections.mysql.port"));
             }, false);
@@ -185,12 +174,10 @@ class ConfigTest extends TestCase
      * Set an environment variable of the given name and value
      * then check the given config key to see if it matches the given result.
      * Providing a null $envVal clears the variable.
-     *
-     * @param mixed $expectedResult
      */
-    protected function checkEnvConfigResult(string $envName, ?string $envVal, string $configKey, $expectedResult)
+    protected function checkEnvConfigResult(string $envName, ?string $envVal, string $configKey, mixed $expectedResult): void
     {
-        $this->runWithEnv($envName, $envVal, function () use ($configKey, $expectedResult) {
+        $this->runWithEnv([$envName => $envVal], function () use ($configKey, $expectedResult) {
             $this->assertEquals($expectedResult, config($configKey));
         });
     }
diff --git a/tests/Uploads/ImageStorageTest.php b/tests/Uploads/ImageStorageTest.php
new file mode 100644
index 000000000..117da7f52
--- /dev/null
+++ b/tests/Uploads/ImageStorageTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Tests\Uploads;
+
+use BookStack\Uploads\ImageStorage;
+use Tests\TestCase;
+
+class ImageStorageTest extends TestCase
+{
+    public function test_local_image_storage_sets_755_directory_permissions()
+    {
+        if (PHP_OS_FAMILY !== 'Linux') {
+            $this->markTestSkipped('Test only works on Linux');
+        }
+
+        config()->set('filesystems.default', 'local');
+        $storage = $this->app->make(ImageStorage::class);
+        $dirToCheck = 'test-dir-perms-' . substr(md5(random_bytes(16)), 0, 6);
+
+        $disk = $storage->getDisk('gallery');
+        $disk->put("{$dirToCheck}/image.png", 'abc', true);
+
+        $expectedPath = public_path("uploads/images/{$dirToCheck}");
+        $permissionsApplied = substr(sprintf('%o', fileperms($expectedPath)), -4);
+        $this->assertEquals('0755', $permissionsApplied);
+
+        @unlink("{$expectedPath}/image.png");
+        @rmdir($expectedPath);
+    }
+}
diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php
index 2c36f5f35..a11f9601f 100644
--- a/tests/Uploads/ImageTest.php
+++ b/tests/Uploads/ImageTest.php
@@ -5,6 +5,8 @@ namespace Tests\Uploads;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageService;
+use BookStack\Uploads\UserAvatars;
+use BookStack\Users\Models\Role;
 use Illuminate\Support\Str;
 use Tests\TestCase;
 
@@ -68,7 +70,24 @@ class ImageTest extends TestCase
         $this->files->deleteAtRelativePath($imgDetails['path']);
 
         $this->assertStringContainsString('thumbs-', $imgDetails['response']->thumbs->gallery);
-        $this->assertStringNotContainsString('thumbs-', $imgDetails['response']->thumbs->display);
+        $this->assertStringNotContainsString('scaled-', $imgDetails['response']->thumbs->display);
+    }
+
+    public function test_image_display_thumbnail_generation_for_animated_avif_images_uses_original_file()
+    {
+        if (! function_exists('imageavif')) {
+            $this->markTestSkipped('imageavif() is not available');
+        }
+
+        $page = $this->entities->page();
+        $admin = $this->users->admin();
+        $this->actingAs($admin);
+
+        $imgDetails = $this->files->uploadGalleryImageToPage($this, $page, 'animated.avif');
+        $this->files->deleteAtRelativePath($imgDetails['path']);
+
+        $this->assertStringContainsString('thumbs-', $imgDetails['response']->thumbs->gallery);
+        $this->assertStringNotContainsString('scaled-', $imgDetails['response']->thumbs->display);
     }
 
     public function test_image_edit()
@@ -450,6 +469,26 @@ class ImageTest extends TestCase
         }
     }
 
+    public function test_avatar_images_visible_only_when_public_access_enabled_with_local_secure_restricted()
+    {
+        config()->set('filesystems.images', 'local_secure_restricted');
+        $user = $this->users->admin();
+        $avatars = $this->app->make(UserAvatars::class);
+        $avatars->assignToUserFromExistingData($user, $this->files->pngImageData(), 'png');
+
+        $avatarUrl = $user->getAvatar();
+
+        $resp = $this->get($avatarUrl);
+        $resp->assertRedirect('/login');
+
+        $this->permissions->makeAppPublic();
+
+        $resp = $this->get($avatarUrl);
+        $resp->assertOk();
+
+        $this->files->deleteAtRelativePath($user->avatar->path);
+    }
+
     public function test_secure_restricted_images_inaccessible_without_relation_permission()
     {
         config()->set('filesystems.images', 'local_secure_restricted');
@@ -474,6 +513,38 @@ class ImageTest extends TestCase
         }
     }
 
+    public function test_secure_restricted_images_accessible_with_public_guest_access()
+    {
+        config()->set('filesystems.images', 'local_secure_restricted');
+        $this->permissions->makeAppPublic();
+
+        $this->asEditor();
+        $page = $this->entities->page();
+        $this->files->uploadGalleryImageToPage($this, $page);
+        $image = Image::query()->where('type', '=', 'gallery')
+            ->where('uploaded_to', '=', $page->id)
+            ->first();
+
+        $expectedUrl = url($image->path);
+        $expectedPath = storage_path($image->path);
+        auth()->logout();
+
+        $this->get($expectedUrl)->assertOk();
+
+        $this->permissions->setEntityPermissions($page, [], []);
+
+        $resp = $this->get($expectedUrl);
+        $resp->assertNotFound();
+
+        $this->permissions->setEntityPermissions($page, ['view'], [Role::getSystemRole('public')]);
+
+        $this->get($expectedUrl)->assertOk();
+
+        if (file_exists($expectedPath)) {
+            unlink($expectedPath);
+        }
+    }
+
     public function test_thumbnail_path_handled_by_secure_restricted_images()
     {
         config()->set('filesystems.images', 'local_secure_restricted');
@@ -649,15 +720,15 @@ class ImageTest extends TestCase
         $galleryFileSize = filesize($galleryThumbPath);
 
         // Basic scan of GIF content to check frame count
-        $originalFrameCount = count(explode("\x00\x21\xF9", file_get_contents($originalFile)));
-        $galleryFrameCount = count(explode("\x00\x21\xF9", file_get_contents($galleryThumbPath)));
+        $originalFrameCount = count(explode("\x00\x21\xF9", file_get_contents($originalFile))) - 1;
+        $galleryFrameCount = count(explode("\x00\x21\xF9", file_get_contents($galleryThumbPath))) - 1;
 
         $this->files->deleteAtRelativePath($relPath);
         $this->files->deleteAtRelativePath($galleryThumbRelPath);
 
         $this->assertNotEquals($originalFileSize, $galleryFileSize);
-        $this->assertEquals(3, $originalFrameCount);
-        $this->assertEquals(1, $galleryFrameCount);
+        $this->assertEquals(2, $originalFrameCount);
+        $this->assertLessThan(2, $galleryFrameCount);
     }
 
     protected function getTestProfileImage()
diff --git a/tests/UrlTest.php b/tests/UrlTest.php
index c1e133804..c1a4d4f1c 100644
--- a/tests/UrlTest.php
+++ b/tests/UrlTest.php
@@ -8,14 +8,14 @@ class UrlTest extends TestCase
 {
     public function test_url_helper_takes_custom_url_into_account()
     {
-        $this->runWithEnv('APP_URL', 'http://example.com/bookstack', function () {
+        $this->runWithEnv(['APP_URL' => 'http://example.com/bookstack'], function () {
             $this->assertEquals('http://example.com/bookstack/books', url('/books'));
         });
     }
 
     public function test_url_helper_sets_correct_scheme_even_when_request_scheme_is_different()
     {
-        $this->runWithEnv('APP_URL', 'https://example.com/', function () {
+        $this->runWithEnv(['APP_URL' => 'https://example.com/'], function () {
             $this->get('http://example.com/login')->assertSee('https://example.com/dist/styles.css');
         });
     }
diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php
index d92f13f0b..d50ac2087 100644
--- a/tests/User/UserManagementTest.php
+++ b/tests/User/UserManagementTest.php
@@ -2,9 +2,21 @@
 
 namespace Tests\User;
 
+use BookStack\Access\Mfa\MfaValue;
+use BookStack\Access\SocialAccount;
 use BookStack\Access\UserInviteException;
 use BookStack\Access\UserInviteService;
 use BookStack\Activity\ActivityType;
+use BookStack\Activity\Models\Activity;
+use BookStack\Activity\Models\Comment;
+use BookStack\Activity\Models\Favourite;
+use BookStack\Activity\Models\View;
+use BookStack\Activity\Models\Watch;
+use BookStack\Api\ApiToken;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Models\PageRevision;
+use BookStack\Exports\Import;
+use BookStack\Uploads\Attachment;
 use BookStack\Uploads\Image;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
@@ -28,10 +40,10 @@ class UserManagementTest extends TestCase
         $this->withHtml($resp)->assertElementContains('form[action="' . url('/settings/users/create') . '"]', 'Save');
 
         $resp = $this->post('/settings/users/create', [
-            'name'                          => $user->name,
-            'email'                         => $user->email,
-            'password'                      => $user->password,
-            'password-confirm'              => $user->password,
+            'name' => $user->name,
+            'email' => $user->email,
+            'password' => $user->password,
+            'password-confirm' => $user->password,
             'roles[' . $adminRole->id . ']' => 'true',
         ]);
         $resp->assertRedirect('/settings/users');
@@ -77,7 +89,7 @@ class UserManagementTest extends TestCase
         $this->get($userProfilePage)->assertSee('Password confirmation required');
 
         $this->put($userProfilePage, [
-            'password'         => 'newpassword',
+            'password' => 'newpassword',
             'password-confirm' => 'newpassword',
         ])->assertRedirect('/settings/users');
 
@@ -165,9 +177,9 @@ class UserManagementTest extends TestCase
         $owner = $page->ownedBy;
         $newOwner = User::query()->where('id', '!=', $owner->id)->first();
 
-        $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id]);
-        $this->assertDatabaseHas('pages', [
-            'id'       => $page->id,
+        $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id])->assertRedirect();
+        $this->assertDatabaseHasEntityData('page', [
+            'id' => $page->id,
             'owned_by' => $newOwner->id,
         ]);
     }
@@ -182,6 +194,91 @@ class UserManagementTest extends TestCase
         $this->assertSessionHas('success');
     }
 
+    public function test_delete_with_empty_owner_migration_id_clears_relevant_id_uses()
+    {
+        $user = $this->users->editor();
+        $page = $this->entities->page();
+        $this->actingAs($user);
+
+        // Create relations
+        $activity = Activity::factory()->create(['user_id' => $user->id]);
+        $attachment = Attachment::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]);
+        $comment = Comment::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]);
+        $deletion = Deletion::factory()->create(['deleted_by' => $user->id]);
+        $page->forceFill(['owned_by' => $user->id, 'created_by' => $user->id, 'updated_by' => $user->id])->save();
+        $page->rebuildPermissions();
+        $image = Image::factory()->create(['created_by' => $user->id, 'updated_by' => $user->id]);
+        $import = Import::factory()->create(['created_by' => $user->id]);
+        $revision = PageRevision::factory()->create(['created_by' => $user->id]);
+
+        $apiToken = ApiToken::factory()->create(['user_id' => $user->id]);
+        \DB::table('email_confirmations')->insert(['user_id' => $user->id, 'token' => 'abc123']);
+        $favourite = Favourite::factory()->create(['user_id' => $user->id]);
+        $mfaValue = MfaValue::factory()->create(['user_id' => $user->id]);
+        $socialAccount = SocialAccount::factory()->create(['user_id' => $user->id]);
+        \DB::table('user_invites')->insert(['user_id' => $user->id, 'token' => 'abc123']);
+        View::incrementFor($page);
+        $watch = Watch::factory()->create(['user_id' => $user->id]);
+
+        $userColumnsByTable = [
+            'api_tokens' => ['user_id'],
+            'attachments' => ['created_by', 'updated_by'],
+            'comments' => ['created_by', 'updated_by'],
+            'deletions' => ['deleted_by'],
+            'email_confirmations' => ['user_id'],
+            'entities' => ['created_by', 'updated_by', 'owned_by'],
+            'favourites' => ['user_id'],
+            'images' => ['created_by', 'updated_by'],
+            'imports' => ['created_by'],
+            'joint_permissions' => ['owner_id'],
+            'mfa_values' => ['user_id'],
+            'page_revisions' => ['created_by'],
+            'role_user' => ['user_id'],
+            'social_accounts' => ['user_id'],
+            'user_invites' => ['user_id'],
+            'views' => ['user_id'],
+            'watches' => ['user_id'],
+        ];
+
+        // Ensure columns have user id before deletion
+        foreach ($userColumnsByTable as $table => $columns) {
+            foreach ($columns as $column) {
+                $this->assertDatabaseHas($table, [$column => $user->id]);
+            }
+        }
+
+        $resp = $this->asAdmin()->delete("settings/users/{$user->id}", ['new_owner_id' => '']);
+        $resp->assertRedirect('/settings/users');
+
+        // Ensure columns missing user id after deletion
+        foreach ($userColumnsByTable as $table => $columns) {
+            foreach ($columns as $column) {
+                $this->assertDatabaseMissing($table, [$column => $user->id]);
+            }
+        }
+
+        // Check models exist where should be retained
+        $this->assertDatabaseHas('attachments', ['id' => $attachment->id, 'created_by' => null, 'updated_by' => null]);
+        $this->assertDatabaseHas('comments', ['id' => $comment->id, 'created_by' => null, 'updated_by' => null]);
+        $this->assertDatabaseHas('deletions', ['id' => $deletion->id, 'deleted_by' => null]);
+        $this->assertDatabaseHas('entities', ['id' => $page->id, 'created_by' => null, 'updated_by' => null, 'owned_by' => null]);
+        $this->assertDatabaseHas('images', ['id' => $image->id, 'created_by' => null, 'updated_by' => null]);
+        $this->assertDatabaseHas('imports', ['id' => $import->id, 'created_by' => null]);
+        $this->assertDatabaseHas('page_revisions', ['id' => $revision->id, 'created_by' => null]);
+
+        // Check models no longer exist where should have been deleted with the user
+        $this->assertDatabaseMissing('api_tokens', ['id' => $apiToken->id]);
+        $this->assertDatabaseMissing('email_confirmations', ['token' => 'abc123']);
+        $this->assertDatabaseMissing('favourites', ['id' => $favourite->id]);
+        $this->assertDatabaseMissing('mfa_values', ['id' => $mfaValue->id]);
+        $this->assertDatabaseMissing('social_accounts', ['id' => $socialAccount->id]);
+        $this->assertDatabaseMissing('user_invites', ['token' => 'abc123']);
+        $this->assertDatabaseMissing('watches', ['id' => $watch->id]);
+
+        // Ensure activity remains using the old ID (Special case for auditing changes)
+        $this->assertDatabaseHas('activities', ['id' => $activity->id, 'user_id' => $user->id]);
+    }
+
     public function test_delete_removes_user_preferences()
     {
         $editor = $this->users->editor();
@@ -247,9 +344,9 @@ class UserManagementTest extends TestCase
         });
 
         $this->asAdmin()->post('/settings/users/create', [
-            'name'                          => $user->name,
-            'email'                         => $user->email,
-            'send_invite'                   => 'true',
+            'name' => $user->name,
+            'email' => $user->email,
+            'send_invite' => 'true',
             'roles[' . $adminRole->id . ']' => 'true',
         ]);
 
@@ -267,9 +364,9 @@ class UserManagementTest extends TestCase
         });
 
         $this->asAdmin()->post('/settings/users/create', [
-            'name'                          => $user->name,
-            'email'                         => $user->email,
-            'send_invite'                   => 'true',
+            'name' => $user->name,
+            'email' => $user->email,
+            'send_invite' => 'true',
         ]);
 
         $this->assertDatabaseMissing('activities', ['type' => 'USER_CREATE']);
@@ -286,9 +383,9 @@ class UserManagementTest extends TestCase
         });
 
         $resp = $this->asAdmin()->post('/settings/users/create', [
-            'name'                          => $user->name,
-            'email'                         => $user->email,
-            'send_invite'                   => 'true',
+            'name' => $user->name,
+            'email' => $user->email,
+            'send_invite' => 'true',
         ]);
 
         $resp->assertRedirect('/settings/users/create');
@@ -314,8 +411,8 @@ class UserManagementTest extends TestCase
         // Both on create
         $resp = $this->post('/settings/users/create', [
             'language' => 'en<GB_and_this_is_longer',
-            'name'     => 'My name',
-            'email'    => 'jimmy@example.com',
+            'name' => 'My name',
+            'email' => 'jimmy@example.com',
         ]);
         $resp->assertSessionHasErrors(['language' => 'The language may not be greater than 15 characters.']);
         $resp->assertSessionHasErrors(['language' => 'The language may only contain letters, numbers, dashes and underscores.']);
diff --git a/tests/Util/DateFormatterTest.php b/tests/Util/DateFormatterTest.php
new file mode 100644
index 000000000..1c0a458e0
--- /dev/null
+++ b/tests/Util/DateFormatterTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Tests\Util;
+
+use BookStack\Util\DateFormatter;
+use Carbon\Carbon;
+use Tests\TestCase;
+
+class DateFormatterTest extends TestCase
+{
+    public function test_iso_with_timezone_alters_from_stored_to_display_timezone()
+    {
+        $formatter = new DateFormatter('Europe/London');
+        $dateTime = new Carbon('2020-06-01 12:00:00', 'UTC');
+
+        $result = $formatter->absolute($dateTime);
+        $this->assertEquals('2020-06-01 13:00:00 BST', $result);
+    }
+
+    public function test_iso_with_timezone_works_from_non_utc_dates()
+    {
+        $formatter = new DateFormatter('Asia/Shanghai');
+        $dateTime = new Carbon('2025-06-10 15:25:00', 'America/New_York');
+
+        $result = $formatter->absolute($dateTime);
+        $this->assertEquals('2025-06-11 03:25:00 CST', $result);
+    }
+
+    public function test_relative()
+    {
+        $formatter = new DateFormatter('Europe/London');
+        $dateTime = (new Carbon('now', 'UTC'))->subMinutes(50);
+
+        $result = $formatter->relative($dateTime);
+        $this->assertEquals('50 minutes ago', $result);
+    }
+}
diff --git a/tests/test-data/animated.avif b/tests/test-data/animated.avif
new file mode 100644
index 000000000..92f71459b
Binary files /dev/null and b/tests/test-data/animated.avif differ
diff --git a/tests/test-data/test-image.jpg b/tests/test-data/test-image.jpg
new file mode 100644
index 000000000..9bb74ff04
Binary files /dev/null and b/tests/test-data/test-image.jpg differ