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

Merge branch 'master' into support/181643-add-contributor

satof3 2 дней назад
Родитель
Сommit
b726927725
42 измененных файлов с 2749 добавлено и 886 удалено
  1. 239 0
      .claude/commands/invest-issue.md
  2. 94 0
      .claude/rules/coding-style.md
  3. 638 0
      .kiro/specs/editor-keymaps/design.md
  4. 189 0
      .kiro/specs/editor-keymaps/requirements.md
  5. 118 0
      .kiro/specs/editor-keymaps/research.md
  6. 22 0
      .kiro/specs/editor-keymaps/spec.json
  7. 147 0
      .kiro/specs/editor-keymaps/tasks.md
  8. 23 149
      .kiro/specs/growi-logger/design.md
  9. 79 0
      .kiro/specs/growi-logger/requirements.md
  10. 1 1
      .kiro/specs/growi-logger/research.md
  11. 2 2
      .kiro/specs/growi-logger/spec.json
  12. 18 0
      .kiro/specs/growi-logger/tasks.md
  13. 0 156
      .kiro/specs/migrate-logger-to-pino/requirements.md
  14. 0 263
      .kiro/specs/migrate-logger-to-pino/tasks.md
  15. 128 0
      apps/app/playwright/23-editor/emacs-keymap.spec.ts
  16. 7 26
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  17. 12 4
      apps/app/src/server/routes/apiv3/forgot-password.js
  18. 12 6
      apps/app/src/server/routes/apiv3/user-activation.ts
  19. 14 9
      apps/app/src/server/routes/apiv3/users.js
  20. 7 5
      apps/app/src/server/routes/login.js
  21. 16 7
      apps/app/src/server/service/global-notification/global-notification-mail.ts
  22. 31 11
      apps/app/src/server/service/installer.ts
  23. 21 0
      apps/app/src/server/util/safe-path-utils.ts
  24. 15 0
      packages/editor/src/client/services-internal/keymaps/default.ts
  25. 40 0
      packages/editor/src/client/services-internal/keymaps/emacs/formatting.ts
  26. 30 0
      packages/editor/src/client/services-internal/keymaps/emacs/index.ts
  27. 251 0
      packages/editor/src/client/services-internal/keymaps/emacs/navigation.ts
  28. 108 0
      packages/editor/src/client/services-internal/keymaps/emacs/structural.ts
  29. 8 9
      packages/editor/src/client/services-internal/keymaps/index.ts
  30. 48 0
      packages/editor/src/client/services-internal/keymaps/keymap-factories.spec.ts
  31. 11 0
      packages/editor/src/client/services-internal/keymaps/types.ts
  32. 19 7
      packages/editor/src/client/services-internal/keymaps/vim.ts
  33. 15 0
      packages/editor/src/client/services-internal/keymaps/vscode.ts
  34. 2 0
      packages/editor/src/client/services-internal/markdown-utils/index.ts
  35. 59 0
      packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.spec.ts
  36. 164 0
      packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.ts
  37. 61 0
      packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.spec.ts
  38. 33 0
      packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.ts
  39. 3 42
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-markdown-elements.ts
  40. 4 161
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-prefix.ts
  41. 15 8
      packages/editor/src/client/stores/use-editor-settings.ts
  42. 45 20
      packages/editor/src/client/stores/use-editor-shortcuts.ts

+ 239 - 0
.claude/commands/invest-issue.md

@@ -0,0 +1,239 @@
+---
+name: invest-issue
+description: Investigate a GitHub issue - fetch info, update labels, analyze code/reproduce, report findings, and optionally fix. Usage: /invest-issue <issue-url-or-number>
+---
+
+# /invest-issue — Issue Investigation
+
+Investigate a GROWI GitHub issue end-to-end: fetch details, label it, analyze or reproduce the problem, report findings, and proceed to fix if approved.
+
+## Input
+
+`$ARGUMENTS` is either:
+- A full GitHub issue URL: `https://github.com/growilabs/growi/issues/99999`
+- An issue number: `99999`
+
+Parse the issue number from whichever form is provided.
+
+## Step 1: Fetch Issue Information
+
+Run the following to get full issue details:
+
+```bash
+gh issue view {ISSUE_NUMBER} --repo growilabs/growi --json number,title,body,labels,comments,createdAt,author,url
+```
+
+Extract and display:
+- Title and URL
+- Description (body)
+- Current labels
+- Reported GROWI version (look for version info in the body/comments)
+- Steps to reproduce (if any)
+- Expected vs actual behavior
+
+## Step 2: Update Labels — Mark as Under Investigation
+
+Remove `phase/new` (if present) and add `phase/under-investigation`:
+
+```bash
+# Remove phase/new
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --remove-label "phase/new"
+
+# Add phase/under-investigation
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "phase/under-investigation"
+```
+
+If `phase/new` is not present, skip the removal step and only add `phase/under-investigation`.
+
+## Step 3: Analyze the Issue
+
+### 3-A: Version Check
+
+1. Determine the reported GROWI version from the issue body or comments.
+2. Get the current master major version:
+   ```bash
+   cat apps/app/package.json | grep '"version"'
+   ```
+3. If the reported major version matches master's major version → proceed with master-branch analysis.
+4. If the reported major version is **older** than master's major version → **STOP analysis** and ask the user:
+
+   > Reported version is v{X}.x, but master is v{Y}.x.
+   > Would you like me to:
+   > 1. **Check out v{X}.x tag/branch** and analyze on that version
+   > 2. **Continue on master** — the issue may still be relevant
+   > 3. **Close as outdated** — skip analysis
+
+   **Wait for the user's response before continuing to Step 3-B.**
+
+### 3-B: Code Investigation
+
+Search the codebase for relevant code related to the reported symptoms:
+
+- Read error messages, stack traces, or behavioral descriptions carefully.
+- Use Grep and Glob to locate relevant files, functions, and modules.
+- Trace the data/execution flow to find the root cause.
+- Check recent commits for related changes:
+  ```bash
+  git log --oneline -20 -- {relevant-file}
+  ```
+
+### 3-C: Reproduction Attempt (if needed)
+
+If code analysis alone is insufficient to confirm the root cause, attempt reproduction:
+
+1. Start the development server:
+   ```bash
+   turbo run dev
+   ```
+2. Follow the reproduction steps from the issue.
+3. Check browser console and server logs for errors.
+
+### 3-D: Label Update on Confirmation
+
+If the problem is **confirmed** (root cause found in code OR reproduction succeeded):
+
+```bash
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --remove-label "phase/under-investigation"
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "phase/confirmed"
+```
+
+## Step 4: Report Findings
+
+> **CRITICAL**: Do NOT modify any source files in this step. Step 4 is analysis and planning only.
+> Implementing code changes before receiving explicit user approval is strictly forbidden.
+
+### 4-A: Report in This Session
+
+Present a clear summary:
+
+```
+## Investigation Results for #{ISSUE_NUMBER}: {TITLE}
+
+**Status**: Confirmed / Unconfirmed / Needs reproduction
+
+### Root Cause
+{Describe what was found — file paths, line numbers, logic errors, etc.}
+
+### Evidence
+{Code snippets, git log entries, or reproduction steps that confirm the finding}
+
+### Fix Plan (not yet implemented)
+{High-level description of the fix approach, if a cause was found.
+List specific files and changes needed, but do NOT apply them yet.}
+```
+
+### 4-B: Post Comment on Issue
+
+Detect the language of the issue body (from Step 1) and write the comment **in the same language**.
+For example, if the issue is written in Japanese, write the comment in Japanese.
+
+Post the findings as a GitHub issue comment:
+
+```bash
+gh issue comment {ISSUE_NUMBER} --repo growilabs/growi --body "$(cat <<'EOF'
+## Investigation Results
+
+**Status**: [Confirmed / Under investigation]
+
+### Root Cause
+{root cause description}
+
+### Evidence
+{relevant code locations, snippets, or reproduction steps}
+
+### Fix Plan
+{fix approach — files and changes needed}
+
+---
+*Investigated by Claude Code*
+EOF
+)"
+```
+
+### 4-C: STOP — Ask for Direction
+
+**STOP HERE. Do not proceed to Step 5 until the user explicitly approves.**
+
+After reporting, ask the user:
+
+> Investigation complete. Root cause [found / not yet confirmed].
+> Would you like me to:
+> 1. **Proceed with the fix** — I'll implement the fix now
+> 2. **Investigate further** — specify what additional analysis is needed
+> 3. **Stop here** — you'll handle the fix manually
+
+**Wait for the user's response before doing anything else.**
+
+## Step 5: Implement the Fix (Only if Approved)
+
+Proceed only after explicit user approval.
+
+### 5-A: Add WIP Label
+
+```bash
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "phase/WIP"
+```
+
+### 5-B: Create a Fix Branch
+
+**Always create a dedicated fix branch before touching any source files.**
+Never commit fixes to `master` or the current branch directly.
+
+Branch naming convention: `fix/{ISSUE_NUMBER}-{short-description}`
+
+```bash
+git checkout -b fix/{ISSUE_NUMBER}-{short-description}
+```
+
+Example: `fix/12345-page-title-overflow`
+
+### 5-C: Implement the Fix
+
+- Make the minimal targeted fix
+- Run lint and tests:
+  ```bash
+  turbo run lint --filter @growi/app
+  turbo run test --filter @growi/app
+  ```
+- Commit with a meaningful message referencing the issue:
+  ```
+  fix(scope): brief description of fix
+
+  Fixes #ISSUE_NUMBER
+  ```
+
+### 5-D: Open a Pull Request
+
+```bash
+gh pr create \
+  --repo growilabs/growi \
+  --title "fix: {brief description}" \
+  --body "$(cat <<'EOF'
+## Summary
+
+{description of the fix}
+
+## Root Cause
+
+{root cause identified during investigation}
+
+## Changes
+
+- {bullet list of changes}
+
+## Test Plan
+
+- [ ] {manual test step 1}
+- [ ] {manual test step 2}
+
+Closes #{ISSUE_NUMBER}
+EOF
+)"
+```
+
+## Error Handling
+
+- If the issue number is invalid or not found: display error from `gh` and stop
+- If `gh` is not authenticated: instruct the user to run `gh auth login`
+- If a label does not exist in the repo: note it in output and skip (don't create new labels)
+- If the dev server fails to start: note this and rely on code analysis only

+ 94 - 0
.claude/rules/coding-style.md

@@ -38,6 +38,97 @@ MANY SMALL FILES > FEW LARGE FILES:
 - Extract utilities from large components
 - Organize by feature/domain, not by type
 
+## Module Design: Separation of Concerns
+
+### Pure Function Extraction
+
+When a framework-specific wrapper (React hook, Express middleware, CodeMirror extension handler, etc.) contains non-trivial logic, extract the core logic as a **pure function** and reduce the wrapper to a thin adapter. This enables direct reuse across different contexts and makes unit testing straightforward.
+
+```typescript
+// ❌ WRONG: Business logic locked inside a framework-specific wrapper
+export const useToggleSymbol = (view?: EditorView) => {
+  return useCallback((prefix, suffix) => {
+    // 30 lines of symbol-toggling logic here...
+  }, [view]);
+};
+
+// ✅ CORRECT: Pure function + thin wrappers for each context
+// services-internal/markdown-utils/toggle-markdown-symbol.ts
+export const toggleMarkdownSymbol = (view: EditorView, prefix: string, suffix: string): void => {
+  // Pure logic — testable, reusable from hooks, keymaps, shortcuts, etc.
+};
+
+// React hook wrapper
+export const useInsertMarkdownElements = (view?: EditorView) => {
+  return useCallback((prefix, suffix) => {
+    if (view == null) return;
+    toggleMarkdownSymbol(view, prefix, suffix);
+  }, [view]);
+};
+
+// Emacs command wrapper
+EmacsHandler.addCommands({
+  markdownBold(handler: { view: EditorView }) {
+    toggleMarkdownSymbol(handler.view, '**', '**');
+  },
+});
+```
+
+**Applies to**: React hooks, Express/Koa middleware, CLI command handlers, CodeMirror extension callbacks, test fixtures — any framework-specific adapter that wraps reusable logic.
+
+### Data-Driven Control over Hard-Coded Mode Checks
+
+Replace conditional branching on mode/variant names with **declared metadata** that consumers filter generically. This eliminates the need to update consumers when adding new modes.
+
+```typescript
+// ❌ WRONG: Consumer knows mode-specific behavior
+if (keymapModeName === 'emacs') {
+  return sharedKeyBindings; // exclude formatting
+}
+return [formattingBindings, ...sharedKeyBindings];
+
+// ✅ CORRECT: Module declares its overrides, consumer filters generically
+// Keymap module returns: { overrides: ['formatting', 'structural'] }
+const activeBindings = allGroups
+  .filter(group => group.category === null || !overrides?.includes(group.category))
+  .flatMap(group => group.bindings);
+```
+
+### Factory Pattern with Encapsulated Metadata
+
+When a module produces a value that requires configuration from the consumer (precedence, feature flags, etc.), **bundle the metadata alongside the value** in a structured return type. This keeps decision-making inside the module that has the knowledge.
+
+```typescript
+// ❌ WRONG: Consumer decides precedence based on mode name
+const wrapWithPrecedence = mode === 'vim' ? Prec.high : Prec.low;
+codeMirrorEditor.appendExtensions(wrapWithPrecedence(keymapExtension));
+
+// ✅ CORRECT: Factory encapsulates its own requirements
+interface KeymapResult {
+  readonly extension: Extension;
+  readonly precedence: (ext: Extension) => Extension;
+  readonly overrides: readonly ShortcutCategory[];
+}
+// Consumer applies generically:
+codeMirrorEditor.appendExtensions(result.precedence(result.extension));
+```
+
+### Responsibility-Based Submodule Decomposition
+
+When a single module grows beyond ~200 lines or accumulates multiple distinct responsibilities, split into submodules **by responsibility domain** (not by arbitrary size). Each submodule should be independently understandable.
+
+```
+// ❌ WRONG: One large file with mixed concerns
+keymaps/emacs.ts  (400+ lines: formatting + structural + navigation + save)
+
+// ✅ CORRECT: Split by responsibility
+keymaps/emacs/
+├── index.ts          ← Factory: composes submodules
+├── formatting.ts     ← Text styling commands
+├── structural.ts     ← Document structure commands
+└── navigation.ts     ← Movement and editing commands
+```
+
 ## Naming Conventions
 
 ### Variables and Functions
@@ -236,3 +327,6 @@ Before marking work complete:
 - [ ] Named exports (except Next.js pages)
 - [ ] English comments
 - [ ] Co-located tests
+- [ ] Non-trivial logic extracted as pure functions from framework wrappers
+- [ ] No hard-coded mode/variant checks in consumers (use declared metadata)
+- [ ] Modules with multiple responsibilities split by domain

+ 638 - 0
.kiro/specs/editor-keymaps/design.md

@@ -0,0 +1,638 @@
+# Design Document: editor-keymaps
+
+## Overview
+
+**Purpose**: This feature refactors the GROWI editor's keymap system into a clean, uniform module architecture and extends Emacs keybindings to cover the full range of markdown-mode operations.
+
+**Users**: Developers maintaining the editor codebase benefit from consistent module boundaries. End users using Emacs keymap mode gain a complete markdown-mode editing experience.
+
+**Impact**: Changes the internal structure of `packages/editor/src/client/services-internal/` and `stores/use-editor-shortcuts.ts`. No external API changes; EditorSettings interface and UI selector remain unchanged.
+
+### Goals
+- Uniform factory interface for all 4 keymap modules with encapsulated precedence and override declarations
+- Eliminate markdown toggle logic duplication between emacs.ts and editor-shortcuts
+- Data-driven shortcut exclusion replacing hard-coded mode checks
+- Relocate `editor-shortcuts/` from public services layer to services-internal where it belongs
+- Complete Emacs markdown-mode keybindings (formatting, structural, navigation, save)
+
+### Non-Goals
+- Changing the keymap selection UI or persistence mechanism (Requirement 8 is verification-only)
+- Adding new keymap modes beyond the existing 4
+- Modifying Vim keybindings beyond structural consistency
+- Full Emacs M-x command palette
+
+## Architecture
+
+### Existing Architecture Analysis
+
+Current module layout and problems:
+
+```
+services/ (PUBLIC API)
+  use-codemirror-editor/
+    utils/
+      insert-markdown-elements.ts   ← hook, exposed via public API ✓
+      insert-prefix.ts              ← hook, exposed via public API ✓
+      editor-shortcuts/             ← NOT exported, only used by stores/ ✗ MISPLACED
+        make-text-bold.ts
+        make-text-italic.ts
+        ...
+
+services-internal/ (INTERNAL)
+  keymaps/
+    index.ts        ← Dispatcher with inline default/vscode logic
+    vim.ts          ← Top-level side effects (Vim.map at module scope)
+    emacs.ts        ← Local toggleMarkdownSymbol duplicating hook logic
+
+stores/
+  use-editor-settings.ts   ← Contains getKeymapPrecedence() mode branching
+  use-editor-shortcuts.ts  ← Hard-coded `if (mode === 'emacs')` exclusion
+```
+
+**Problems**:
+1. `editor-shortcuts/` is in public `services/` tree but never exported — layer violation
+2. No dedicated module for default/vscode modes
+3. Precedence logic leaked to consumer (`getKeymapPrecedence`)
+4. Override knowledge leaked to shortcut registration (`if emacs` check)
+5. Markdown toggle duplicated in emacs.ts vs `useInsertMarkdownElements`
+6. emacs.ts will accumulate 19+ commands in a single file — low cohesion
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph consts
+        KeyMapMode[KeyMapMode type]
+        KeymapResult[KeymapResult interface]
+        ShortcutCategory[ShortcutCategory type]
+    end
+
+    subgraph services-internal
+        subgraph markdown-utils
+            ToggleSymbol[toggleMarkdownSymbol]
+            LinePrefix[insertLinePrefix]
+        end
+        subgraph keymaps
+            Dispatcher[index.ts dispatcher]
+            DefaultMod[default.ts]
+            VscodeMod[vscode.ts]
+            VimMod[vim.ts]
+            subgraph emacs
+                EmacsIndex[emacs/index.ts]
+                EmacsFormatting[emacs/formatting.ts]
+                EmacsStructural[emacs/structural.ts]
+                EmacsNavigation[emacs/navigation.ts]
+            end
+        end
+        subgraph editor-shortcuts
+            ShortcutDefs[CategorizedKeyBindings definitions]
+        end
+    end
+
+    subgraph services-public[services - public]
+        InsertMdHook[useInsertMarkdownElements]
+        InsertPrefixHook[useInsertPrefix]
+    end
+
+    subgraph stores
+        EditorSettings[useEditorSettings]
+        EditorShortcuts[useEditorShortcuts]
+    end
+
+    Dispatcher --> DefaultMod
+    Dispatcher --> VscodeMod
+    Dispatcher --> VimMod
+    Dispatcher --> EmacsIndex
+    EmacsIndex --> EmacsFormatting
+    EmacsIndex --> EmacsStructural
+    EmacsIndex --> EmacsNavigation
+    EmacsFormatting --> ToggleSymbol
+    EmacsStructural --> LinePrefix
+    InsertMdHook --> ToggleSymbol
+    InsertPrefixHook --> LinePrefix
+    EditorSettings --> Dispatcher
+    EditorSettings --> EditorShortcuts
+    EditorShortcuts --> ShortcutDefs
+    EditorShortcuts -.->|reads overrides| KeymapResult
+```
+
+**Architecture Integration**:
+- Selected pattern: Factory with structured return object (see `research.md` — Pattern A)
+- Domain boundaries: Each keymap module owns its bindings, precedence, and override declarations
+- Emacs module split into submodules by responsibility (formatting / structural / navigation)
+- Pure functions in `markdown-utils/` shared by both public hooks and internal keymaps
+- `editor-shortcuts/` relocated to `services-internal/` to match its actual visibility
+- Existing patterns preserved: Async lazy loading, `appendExtensions` lifecycle
+- Steering compliance: Feature-based organization, named exports, immutability, high cohesion
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Frontend | CodeMirror 6 (@codemirror/view, @codemirror/state) | Extension system, keymap API | Existing |
+| Frontend | @replit/codemirror-emacs 6.1.0 | EmacsHandler.bindKey/addCommands | Existing |
+| Frontend | @replit/codemirror-vim 6.2.1 | Vim.map/defineEx | Existing |
+| Frontend | @replit/codemirror-vscode-keymap 6.0.2 | VSCode keybindings | Existing |
+
+No new dependencies introduced.
+
+## System Flows
+
+### Keymap Loading Flow
+
+```mermaid
+sequenceDiagram
+    participant Settings as useEditorSettings
+    participant Dispatcher as getKeymap
+    participant Module as KeymapModule
+    participant CM as CodeMirror
+
+    Settings->>Dispatcher: getKeymap(mode, onSave)
+    Dispatcher->>Module: module.create(onSave?)
+    Module-->>Dispatcher: KeymapResult
+    Dispatcher-->>Settings: KeymapResult
+    Settings->>CM: appendExtensions(result.precedence(result.extension))
+    Settings->>CM: pass result.overrides to useEditorShortcuts
+```
+
+Key decisions:
+- Dispatcher is a thin router; all logic lives in modules
+- `KeymapResult.precedence` is a function (`Prec.high` or `Prec.low`) applied by the consumer
+- `overrides` array flows to shortcut registration for data-driven exclusion
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1 | Dedicated module per mode | default.ts, vscode.ts, vim.ts, emacs/ | KeymapFactory | Keymap Loading |
+| 1.2 | Uniform async factory interface | All keymap modules | KeymapFactory | Keymap Loading |
+| 1.3 | No inline logic in dispatcher | keymaps/index.ts | — | Keymap Loading |
+| 1.4 | Encapsulated precedence | KeymapResult interface | KeymapResult | Keymap Loading |
+| 2.1 | Shared toggle utility | markdown-utils/toggleMarkdownSymbol | — | — |
+| 2.2 | Emacs uses shared logic | emacs/formatting.ts | — | — |
+| 2.3 | No duplicate toggle impl | Remove local emacs.ts toggle | — | — |
+| 3.1 | Keymap declares overrides | KeymapResult.overrides | ShortcutCategory | — |
+| 3.2 | Shortcut registration consults overrides | useEditorShortcuts | CategorizedKeyBindings | — |
+| 3.3 | New modes need no shortcut changes | Data-driven exclusion | ShortcutCategory | — |
+| 4.1-4.5 | Existing Emacs formatting bindings | emacs/formatting.ts | EmacsHandler | — |
+| 5.1-5.7 | Emacs structural bindings | emacs/structural.ts | EmacsHandler | — |
+| 6.1-6.2 | Emacs C-x C-s save | emacs/index.ts | KeymapFactory (onSave) | — |
+| 7.1-7.2 | Vim module consistency | vim.ts | KeymapFactory | — |
+| 8.1-8.3 | UI consistency | OptionsSelector | — | — |
+| 9.1-9.9 | Extended markdown-mode bindings | emacs/navigation.ts | EmacsHandler | — |
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|-------------|--------|--------------|------------------|-----------|
+| KeymapResult | consts | Structured return type for keymap factories | 1.2, 1.4, 3.1 | — | Type |
+| ShortcutCategory | consts | Override category type | 3.1, 3.2, 3.3 | — | Type |
+| CategorizedKeyBindings | consts | KeyBindings grouped by category | 3.2 | — | Type |
+| toggleMarkdownSymbol | markdown-utils | Pure function for markdown wrap/unwrap | 2.1, 2.2, 2.3 | @codemirror/state | Service |
+| insertLinePrefix | markdown-utils | Pure function for line prefix operations | 5.1, 5.3, 5.4, 5.5 | @codemirror/state | Service |
+| keymaps/default.ts | keymaps | Default keymap module | 1.1 | @codemirror/commands | Service |
+| keymaps/vscode.ts | keymaps | VSCode keymap module | 1.1 | @replit/codemirror-vscode-keymap | Service |
+| keymaps/vim.ts | keymaps | Vim keymap module (refactored) | 1.1, 7.1, 7.2 | @replit/codemirror-vim | Service |
+| keymaps/emacs/ | keymaps | Emacs keymap module (split by responsibility) | 1.1, 4-6, 9 | @replit/codemirror-emacs | Service |
+| keymaps/index.ts | keymaps | Thin dispatcher | 1.2, 1.3 | All keymap modules | Service |
+| editor-shortcuts/ | services-internal | Categorized shortcut definitions | 3.2 | markdown-utils | Service |
+| useEditorShortcuts | stores | Data-driven shortcut registration | 3.1, 3.2, 3.3 | editor-shortcuts, KeymapResult | State |
+| useEditorSettings | stores | Keymap lifecycle (simplified) | 1.4 | getKeymap | State |
+| OptionsSelector | UI | Keymap selector (no changes) | 8.1-8.3 | — | — |
+
+### Consts Layer
+
+#### KeymapResult Interface
+
+| Field | Detail |
+|-------|--------|
+| Intent | Structured return type encapsulating keymap extension, precedence, and override metadata |
+| Requirements | 1.2, 1.4, 3.1 |
+
+**Contracts**: Type [x]
+
+```typescript
+type ShortcutCategory = 'formatting' | 'structural' | 'navigation';
+
+interface KeymapResult {
+  readonly extension: Extension;
+  readonly precedence: (ext: Extension) => Extension; // Prec.high or Prec.low
+  readonly overrides: readonly ShortcutCategory[];
+}
+
+type KeymapFactory = (onSave?: () => void) => Promise<KeymapResult>;
+```
+
+#### CategorizedKeyBindings Type
+
+| Field | Detail |
+|-------|--------|
+| Intent | Group KeyBindings by ShortcutCategory for data-driven exclusion |
+| Requirements | 3.2 |
+
+**Contracts**: Type [x]
+
+```typescript
+interface CategorizedKeyBindings {
+  readonly category: ShortcutCategory | null; // null = always included (e.g., multiCursor)
+  readonly bindings: readonly KeyBinding[];
+}
+```
+
+Each shortcut definition module returns a `CategorizedKeyBindings` object instead of raw `KeyBinding[]`. `null` category means always active regardless of overrides.
+
+### Shared Utils Layer (`services-internal/markdown-utils/`)
+
+Pure functions usable by both public hooks and internal keymaps. No React dependencies.
+
+#### toggleMarkdownSymbol
+
+| Field | Detail |
+|-------|--------|
+| Intent | Pure function to wrap/unwrap selected text with markdown symbols |
+| Requirements | 2.1, 2.2, 2.3 |
+
+**Contracts**: Service [x]
+
+```typescript
+/**
+ * Toggle markdown symbols around the current selection.
+ * If the selection is already wrapped with prefix/suffix, remove them.
+ * If no text is selected, insert prefix+suffix and position cursor between them.
+ */
+const toggleMarkdownSymbol: (
+  view: EditorView,
+  prefix: string,
+  suffix: string,
+) => void;
+```
+
+**Implementation Notes**
+- Extracted from current `emacs.ts` local function
+- `useInsertMarkdownElements` hook in `services/` becomes a thin wrapper: `useCallback((p, s) => toggleMarkdownSymbol(view, p, s), [view])`
+- Location: `packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.ts`
+
+#### insertLinePrefix
+
+| Field | Detail |
+|-------|--------|
+| Intent | Pure function to insert/toggle prefix at line beginnings |
+| Requirements | 5.1, 5.3, 5.4, 5.5 |
+
+**Contracts**: Service [x]
+
+```typescript
+/**
+ * Insert or toggle a prefix at the beginning of the current line(s).
+ * Handles multi-line selections. Removes prefix if all lines already have it.
+ */
+const insertLinePrefix: (
+  view: EditorView,
+  prefix: string,
+  noSpaceIfPrefixExists?: boolean,
+) => void;
+```
+
+**Implementation Notes**
+- Extracted from current `useInsertPrefix` hook
+- Hook becomes a thin wrapper
+- Location: `packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.ts`
+
+#### Dependency Direction
+
+```
+services/ (public hooks)
+  useInsertMarkdownElements ──imports──> services-internal/markdown-utils/toggleMarkdownSymbol
+  useInsertPrefix           ──imports──> services-internal/markdown-utils/insertLinePrefix
+
+services-internal/ (internal)
+  keymaps/emacs/formatting  ──imports──> services-internal/markdown-utils/toggleMarkdownSymbol
+  keymaps/emacs/structural  ──imports──> services-internal/markdown-utils/insertLinePrefix
+  editor-shortcuts/         ──imports──> services-internal/markdown-utils/ (via pure functions)
+```
+
+Both public hooks and internal modules depend on the same internal pure functions.
+
+**Layer Rule Exception**: `markdown-utils/` is explicitly designated as a **shared pure-function sublayer** within `services-internal/`. Public hooks in `services/` are permitted to import from `services-internal/markdown-utils/` as thin wrappers. This exception is scoped to pure functions with no React dependencies — other `services-internal/` modules remain off-limits to `services/`. This pattern avoids duplication (Req 2.3) while keeping the public API surface minimal.
+
+### Keymaps Layer
+
+#### keymaps/default.ts
+
+| Field | Detail |
+|-------|--------|
+| Intent | Default CodeMirror keymap module |
+| Requirements | 1.1 |
+
+**Contracts**: Service [x]
+
+```typescript
+const defaultKeymap: KeymapFactory;
+// Returns:
+// - extension: keymap.of(defaultKeymap from @codemirror/commands)
+// - precedence: Prec.low
+// - overrides: [] (no overrides)
+```
+
+#### keymaps/vscode.ts
+
+| Field | Detail |
+|-------|--------|
+| Intent | VSCode keymap module |
+| Requirements | 1.1 |
+
+**Contracts**: Service [x]
+
+```typescript
+const vscodeKeymap: KeymapFactory;
+// Returns:
+// - extension: keymap.of(vscodeKeymap from @replit/codemirror-vscode-keymap)
+// - precedence: Prec.low
+// - overrides: [] (no overrides)
+```
+
+#### keymaps/vim.ts (Refactored)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Vim keymap module with side effects encapsulated in factory |
+| Requirements | 1.1, 7.1, 7.2 |
+
+**Responsibilities & Constraints**
+- Moves `Vim.map('jj', '<Esc>', 'insert')` and `Vim.map('jk', '<Esc>', 'insert')` inside factory
+- Registers `:w` ex-command inside factory when onSave provided
+- Uses idempotency guard to prevent duplicate registration on re-import
+
+**Contracts**: Service [x]
+
+```typescript
+const vimKeymap: KeymapFactory;
+// Returns:
+// - extension: vim()
+// - precedence: Prec.high
+// - overrides: [] (Vim uses its own modal system, no standard shortcut conflicts)
+```
+
+#### keymaps/emacs/ (Split Module)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Emacs keymap module split by responsibility for high cohesion |
+| Requirements | 1.1, 4.1-4.5, 5.1-5.7, 6.1-6.2, 9.1-9.9 |
+
+**Module Structure**:
+```
+keymaps/emacs/
+├── index.ts          ← Factory: composes submodules, registers with EmacsHandler, returns KeymapResult
+├── formatting.ts     ← C-c C-s formatting commands (bold, italic, code, strikethrough, code block)
+├── structural.ts     ← C-c C-s/C-c C- structural commands (headings, lists, blockquote, link, HR)
+└── navigation.ts     ← C-c C- navigation/editing commands (heading nav, promote/demote, kill, image, table)
+```
+
+**Submodule Responsibilities**:
+
+Each submodule exports a registration function:
+```typescript
+type EmacsBindingRegistrar = (
+  EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
+  options?: { onSave?: () => void },
+) => void;
+```
+
+**emacs/index.ts** — Factory & Composition:
+```typescript
+const emacsKeymap: KeymapFactory;
+// 1. Dynamically imports @replit/codemirror-emacs
+// 2. Calls registerFormattingBindings(EmacsHandler)
+// 3. Calls registerStructuralBindings(EmacsHandler)
+// 4. Calls registerNavigationBindings(EmacsHandler)
+// 5. Registers save: C-x C-s → onSave callback
+// 6. Returns { extension: emacs(), precedence: Prec.high, overrides: ['formatting', 'structural'] }
+```
+
+**emacs/formatting.ts** — Req 4.1-4.5:
+
+| Command Name | Binding | Action |
+|-------------|---------|--------|
+| markdownBold | `C-c C-s b\|C-c C-s S-b` | toggleMarkdownSymbol(view, '**', '**') |
+| markdownItalic | `C-c C-s i\|C-c C-s S-i` | toggleMarkdownSymbol(view, '*', '*') |
+| markdownCode | `C-c C-s c` | toggleMarkdownSymbol(view, '`', '`') |
+| markdownStrikethrough | `C-c C-s s` | toggleMarkdownSymbol(view, '~~', '~~') |
+| markdownCodeBlock | `C-c C-s p` | toggleMarkdownSymbol(view, '```\n', '\n```') |
+
+**emacs/structural.ts** — Req 5.1-5.7:
+
+| Command Name | Binding | Action |
+|-------------|---------|--------|
+| markdownBlockquote | `C-c C-s q` | insertLinePrefix(view, '>') |
+| markdownLink | `C-c C-l` | toggleMarkdownSymbol(view, '[', ']()') |
+| markdownHorizontalRule | `C-c C-s -` | Insert '---' at current line |
+| markdownHeadingDwim | `C-c C-s h` | Auto-determine heading level |
+| markdownHeading1-6 | `C-c C-s 1`~`6` | insertLinePrefix(view, '# '...'###### ') |
+| markdownNewListItem | `C-c C-j` | Insert new list item matching context |
+| markdownFencedCodeBlock | `C-c C-s S-c` | Insert GFM fenced code block |
+
+**emacs/navigation.ts** — Req 9.1-9.9:
+
+**Multi-Key Prefix Compatibility Note**: All `C-c C-{key}` bindings use the same 2-stroke prefix mechanism validated in PR #10980 (`C-c C-s` prefix). `EmacsHandler.bindKey` supports multi-key sequences where `C-c` acts as a prefix map — subsequent keystrokes (`C-n`, `C-f`, `C-b`, `C-p`, etc.) are dispatched from the prefix map, not as standalone Emacs commands. This has been confirmed working with the `C-c C-s` prefix in production. If any binding conflicts with a base Emacs command (e.g., `C-c C-f` shadowing `forward-char` after `C-c`), the prefix map takes priority by design — the base command remains accessible without the `C-c` prefix.
+
+| Command Name | Binding | Action |
+|-------------|---------|--------|
+| markdownPromote | `C-c C--` | Decrease heading level or outdent list |
+| markdownDemote | `C-c C-=` | Increase heading level or indent list |
+| markdownNextHeading | `C-c C-n` | Navigate to next heading |
+| markdownPrevHeading | `C-c C-p` | Navigate to previous heading |
+| markdownNextSiblingHeading | `C-c C-f` | Navigate to next heading at same level |
+| markdownPrevSiblingHeading | `C-c C-b` | Navigate to previous heading at same level |
+| markdownUpHeading | `C-c C-u` | Navigate to parent heading |
+| markdownKill | `C-c C-k` | Kill element at point |
+| markdownImage | `C-c C-i` | Insert image template |
+| markdownTable | `C-c C-s t` | Insert table template |
+| markdownFootnote | `C-c C-s f` | Insert footnote pair |
+
+#### keymaps/index.ts (Simplified Dispatcher)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Thin routing dispatcher delegating to keymap modules |
+| Requirements | 1.2, 1.3 |
+
+**Contracts**: Service [x]
+
+```typescript
+const getKeymap: (
+  keyMapName?: KeyMapMode,
+  onSave?: () => void,
+) => Promise<KeymapResult>;
+```
+
+Implementation is a simple switch delegating to each module's factory. No inline keymap construction.
+
+### Editor Shortcuts Layer (`services-internal/editor-shortcuts/`)
+
+Relocated from `services/use-codemirror-editor/utils/editor-shortcuts/`.
+
+| Field | Detail |
+|-------|--------|
+| Intent | Categorized shortcut definitions for data-driven registration |
+| Requirements | 3.2 |
+
+**Key Change**: Each shortcut module returns `CategorizedKeyBindings` instead of raw `KeyBinding`:
+
+```typescript
+// Example: formatting shortcuts
+const formattingKeyBindings: (view?: EditorView, keymapMode?: KeyMapMode) => CategorizedKeyBindings;
+// Returns: { category: 'formatting', bindings: [bold, italic, strikethrough, code] }
+
+// Example: structural shortcuts
+const structuralKeyBindings: (view?: EditorView) => CategorizedKeyBindings;
+// Returns: { category: 'structural', bindings: [numbered, bullet, blockquote, link] }
+
+// Example: always-on shortcuts
+const alwaysOnKeyBindings: () => CategorizedKeyBindings;
+// Returns: { category: null, bindings: [...multiCursor] }
+```
+
+**Implementation Notes**:
+- Individual shortcut files (make-text-bold.ts, etc.) remain as-is internally but are grouped by the categorized wrapper
+- `generateAddMarkdownSymbolCommand` refactored to use pure `toggleMarkdownSymbol` directly instead of via hook
+- Move path: `services/use-codemirror-editor/utils/editor-shortcuts/` → `services-internal/editor-shortcuts/`
+
+### Stores Layer
+
+#### useEditorShortcuts (Refactored)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Data-driven shortcut registration using keymap override metadata |
+| Requirements | 3.1, 3.2, 3.3 |
+
+**Contracts**: State [x]
+
+```typescript
+const useEditorShortcuts: (
+  codeMirrorEditor?: UseCodeMirrorEditor,
+  overrides?: readonly ShortcutCategory[],
+) => void;
+```
+
+**Key Change**: Parameter changes from `keymapModeName?: KeyMapMode` to `overrides?: readonly ShortcutCategory[]`.
+
+Exclusion logic:
+```typescript
+const allGroups: CategorizedKeyBindings[] = [
+  formattingKeyBindings(view, keymapMode),
+  structuralKeyBindings(view),
+  alwaysOnKeyBindings(),
+];
+
+const activeBindings = allGroups
+  .filter(group => group.category === null || !overrides?.includes(group.category))
+  .flatMap(group => group.bindings);
+```
+
+#### useEditorSettings (Simplified)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Keymap lifecycle with simplified precedence handling |
+| Requirements | 1.4 |
+
+**Key Change**: Remove `getKeymapPrecedence()` function. Use `keymapResult.precedence` directly:
+
+```typescript
+// Before:
+const wrapWithPrecedence = getKeymapPrecedence(keymapMode);
+codeMirrorEditor?.appendExtensions(wrapWithPrecedence(keymapExtension));
+
+// After:
+codeMirrorEditor?.appendExtensions(keymapResult.precedence(keymapResult.extension));
+```
+
+Pass `keymapResult.overrides` to `useEditorShortcuts` instead of `keymapMode`.
+
+## Target Directory Structure
+
+```
+packages/editor/src/client/
+├── services/                              (PUBLIC API — unchanged contract)
+│   └── use-codemirror-editor/
+│       ├── use-codemirror-editor.ts       (hook: wraps pure functions for public API)
+│       └── utils/
+│           ├── insert-markdown-elements.ts (thin wrapper → markdown-utils/toggleMarkdownSymbol)
+│           ├── insert-prefix.ts           (thin wrapper → markdown-utils/insertLinePrefix)
+│           └── ...                        (other utils unchanged)
+│           (editor-shortcuts/ REMOVED — moved to services-internal/)
+│
+├── services-internal/
+│   ├── markdown-utils/                    (NEW: pure functions, no React deps)
+│   │   ├── index.ts
+│   │   ├── toggle-markdown-symbol.ts
+│   │   └── insert-line-prefix.ts
+│   ├── keymaps/
+│   │   ├── index.ts                       (thin dispatcher)
+│   │   ├── types.ts                       (KeymapResult, KeymapFactory, ShortcutCategory)
+│   │   ├── default.ts                     (NEW)
+│   │   ├── vscode.ts                      (NEW)
+│   │   ├── vim.ts                         (refactored: side effects inside factory)
+│   │   └── emacs/                         (SPLIT from single file)
+│   │       ├── index.ts                   (factory + composition)
+│   │       ├── formatting.ts              (C-c C-s formatting bindings)
+│   │       ├── structural.ts              (C-c C-s/C-c C- structural bindings)
+│   │       └── navigation.ts              (C-c C- navigation/editing bindings)
+│   ├── editor-shortcuts/                  (MOVED from services/)
+│   │   ├── index.ts                       (re-exports CategorizedKeyBindings groups)
+│   │   ├── types.ts                       (CategorizedKeyBindings)
+│   │   ├── formatting.ts                  (bold, italic, strikethrough, code)
+│   │   ├── structural.ts                  (numbered, bullet, blockquote, link)
+│   │   ├── always-on.ts                   (multiCursor)
+│   │   ├── make-code-block-extension.ts   (4-key combo as Extension)
+│   │   └── generate-add-markdown-symbol-command.ts
+│   └── ...                                (other services-internal unchanged)
+│
+└── stores/
+    ├── use-editor-settings.ts             (simplified: no getKeymapPrecedence)
+    └── use-editor-shortcuts.ts            (refactored: category-based exclusion)
+```
+
+### Affected Files: `editor-shortcuts/` Relocation
+
+Moving `editor-shortcuts/` from `services/use-codemirror-editor/utils/` to `services-internal/` affects the following import paths:
+
+| File | Import Count | Change |
+|------|-------------|--------|
+| `stores/use-editor-shortcuts.ts` | 10 imports | Rewrite all `../services/use-codemirror-editor/utils/editor-shortcuts/` → `../services-internal/editor-shortcuts/` |
+| `stores/use-editor-settings.ts` | 1 import (indirect via `use-editor-shortcuts`) | No change needed (imports `useEditorShortcuts` hook, not shortcuts directly) |
+
+No other files in the codebase import from `editor-shortcuts/`. The relocation is self-contained within `stores/use-editor-shortcuts.ts` import rewrites plus the physical directory move.
+
+## Data Models
+
+No data model changes. EditorSettings interface and localStorage persistence remain unchanged.
+
+## Error Handling
+
+### Error Strategy
+- EmacsHandler command registration failures: Log warning, continue with base emacs bindings
+- Missing onSave callback: Silently ignore C-x C-s / :w (6.2)
+- Duplicate command registration: Idempotency guard prevents double-registration
+
+## Testing Strategy
+
+### Unit Tests
+- `toggleMarkdownSymbol`: wrap, unwrap, empty selection, nested symbols — 5+ cases
+- `insertLinePrefix`: single line, multi-line, toggle off, with indent — 4+ cases
+- Each keymap factory returns correct `KeymapResult` shape (precedence, overrides)
+- `CategorizedKeyBindings` exclusion logic with various override combinations
+- Emacs submodule registration: formatting, structural, navigation each register expected commands
+
+### Integration Tests
+- Emacs mode: C-c C-s b toggles bold in editor
+- Emacs mode: C-x C-s triggers save callback
+- Vim mode: :w triggers save callback
+- Mode switching preserves document content
+- Shortcut exclusion: formatting shortcuts absent in Emacs mode, present in default mode
+
+### E2E Tests
+- Extend existing `playwright/23-editor/vim-keymap.spec.ts` pattern for Emacs keybindings
+- Keymap selector switches modes without reload

+ 189 - 0
.kiro/specs/editor-keymaps/requirements.md

@@ -0,0 +1,189 @@
+# Requirements Document
+
+## Introduction
+
+GROWI のエディタは CodeMirror 6 をベースに、4 つのキーマップモード(default, vscode, vim, emacs)をサポートしている。本仕様では以下の 2 つの目的を達成する:
+
+1. **モジュール構成のリファクタリング**: 各キーマップモードの責務・モジュール境界を整理し、一貫性のあるクリーンなアーキテクチャにリファクタする
+2. **Emacs キーバインディングの拡充**: PR #10980 で導入された Emacs markdown-mode バインディング(`C-c C-s` プレフィックス)を拡張し、本家 [jrblevin/markdown-mode](https://github.com/jrblevin/markdown-mode) を参考にした網羅的な Markdown 操作バインディングを提供する
+
+### Priority Order
+
+- **高優先**: Requirement 1-3 (モジュールリファクタリング) → Requirement 6-7 (save/vim 一貫性) → Requirement 8 (UI)
+- **中優先**: Requirement 4 (既存 formatting bindings の維持)
+- **低優先**: Requirement 5, 9 (追加 Emacs バインディング) — 本家 markdown-mode 準拠の拡充は最後に対応
+
+### Current State (PR #10980)
+
+- `packages/editor/src/client/services-internal/keymaps/` に vim.ts, emacs.ts が存在し、index.ts がディスパッチャ
+- default と vscode は index.ts 内でインラインに処理されており、独立モジュールがない
+- `toggleMarkdownSymbol` が emacs.ts 内にローカル実装されており、既存の `generateAddMarkdownSymbolCommand` / `useInsertMarkdownElements` と責務が重複
+- `use-editor-shortcuts.ts` が emacs モード判定のための条件分岐を持ち、各キーマップの差異を外部から管理している
+- Emacs モードでは formatting 系ショートカット(bold, italic, strikethrough, code)のみ C-c C-s で提供、リスト・引用・リンク等は未対応
+
+### Reference: jrblevin/markdown-mode Keybindings
+
+本家 Emacs markdown-mode の主要キーバインド(実装対象の参照用):
+
+**Text Styling (C-c C-s)**
+| Key | Command |
+|-----|---------|
+| `C-c C-s i` | Italic |
+| `C-c C-s b` | Bold |
+| `C-c C-s c` | Inline code |
+| `C-c C-s k` | `<kbd>` tag |
+| `C-c C-s q` / `C-c C-s Q` | Blockquote (word / region) |
+| `C-c C-s p` / `C-c C-s P` | Preformatted code block (word / region) |
+| `C-c C-s C` | GFM fenced code block |
+| `C-c C-s s` | Strikethrough (GROWI extension, not in original) |
+
+**Headings (C-c C-s)**
+| Key | Command |
+|-----|---------|
+| `C-c C-s h` / `C-c C-s H` | Auto heading (atx / setext) |
+| `C-c C-s 1` ~ `C-c C-s 6` | ATX heading level 1-6 |
+| `C-c C-s !` | Setext heading level 1 |
+| `C-c C-s @` | Setext heading level 2 |
+
+**Links & Images (C-c C-)**
+| Key | Command |
+|-----|---------|
+| `C-c C-l` | Insert/edit link |
+| `C-c C-i` | Insert/edit image |
+
+**Horizontal Rule & Footnotes (C-c C-s)**
+| Key | Command |
+|-----|---------|
+| `C-c C-s -` | Horizontal rule |
+| `C-c C-s f` | Footnote |
+| `C-c C-s w` | Wiki link |
+| `C-c C-s t` | Table |
+
+**Promotion & Demotion**
+| Key | Command |
+|-----|---------|
+| `C-c C--` / `C-c LEFT` | Promote (outdent) |
+| `C-c C-=` / `C-c RIGHT` | Demote (indent) |
+
+**List Editing**
+| Key | Command |
+|-----|---------|
+| `M-RET` / `C-c C-j` | New list item |
+| `C-c UP/DOWN` | Move list item up/down |
+
+**Outline Navigation**
+| Key | Command |
+|-----|---------|
+| `C-c C-n` / `C-c C-p` | Next/previous heading (any level) |
+| `C-c C-f` / `C-c C-b` | Next/previous heading (same level) |
+| `C-c C-u` | Up to parent heading |
+
+**Other**
+| Key | Command |
+|-----|---------|
+| `C-c C-k` | Kill element at point |
+| `C-c C-o` | Open link at point |
+| `C-c C-x C-s` / `C-x C-s` | Save |
+
+## Requirements
+
+### Requirement 1: Uniform Keymap Module Structure
+
+**Objective:** As a developer, I want each keymap mode to have a consistent module structure, so that adding or modifying keymaps follows a predictable pattern and reduces coupling.
+
+#### Acceptance Criteria
+
+1. The Editor shall provide a dedicated module file for each keymap mode (default, vscode, vim, emacs) under `keymaps/` directory.
+2. When a keymap mode is loaded, the Keymap Dispatcher shall delegate to the corresponding module via the same async factory interface (`() => Promise<Extension>`).
+3. The Editor shall not contain inline keymap construction logic in the dispatcher; all mode-specific logic shall reside in each mode's dedicated module.
+4. Each keymap module shall encapsulate its own precedence requirement (high/low) so that the consumer does not need mode-specific branching for precedence.
+
+### Requirement 2: Shared Markdown Formatting Utility
+
+**Objective:** As a developer, I want markdown symbol toggling logic to be shared across keymap modules and editor shortcuts, so that formatting behavior is consistent and not duplicated.
+
+#### Acceptance Criteria
+
+1. The Editor shall provide a single shared utility for toggling markdown symbols (wrap/unwrap with prefix/suffix) that can be used by both keymap modules and editor shortcut hooks.
+2. When the Emacs keymap module applies markdown formatting, the Editor shall use the same toggling logic as the standard editor shortcuts.
+3. The Editor shall not have duplicate implementations of markdown symbol toggling in separate modules.
+
+### Requirement 3: Keymap-Aware Shortcut Registration
+
+**Objective:** As a developer, I want each keymap module to declare which standard shortcuts it overrides, so that the shortcut registration layer can exclude conflicts without hard-coded mode checks.
+
+#### Acceptance Criteria
+
+1. Each keymap module shall declare which categories of editor shortcuts it handles internally (e.g., formatting, navigation).
+2. When editor shortcuts are registered, the Shortcut Registration Hook shall consult the active keymap's declared overrides to exclude conflicting bindings.
+3. If a new keymap mode is added, the Shortcut Registration Hook shall not require code changes to handle the new mode's overrides.
+
+### Requirement 4: Emacs Markdown-Mode Formatting Bindings (Existing)
+
+**Objective:** As an Emacs user, I want C-c C-s prefix keybindings for markdown formatting, so that I can use familiar Emacs markdown-mode conventions in the GROWI editor.
+
+#### Acceptance Criteria
+
+1. While Emacs keymap mode is active, when the user types `C-c C-s b` or `C-c C-s B`, the Editor shall toggle bold formatting (`**`) around the selection or at the cursor.
+2. While Emacs keymap mode is active, when the user types `C-c C-s i` or `C-c C-s I`, the Editor shall toggle italic formatting (`*`) around the selection or at the cursor.
+3. While Emacs keymap mode is active, when the user types `C-c C-s c`, the Editor shall toggle inline code formatting (`` ` ``) around the selection or at the cursor.
+4. While Emacs keymap mode is active, when the user types `C-c C-s s`, the Editor shall toggle strikethrough formatting (`~~`) around the selection or at the cursor.
+5. While Emacs keymap mode is active, when the user types `C-c C-s p`, the Editor shall toggle code block formatting (` ``` `) around the selection or at the cursor.
+
+### Requirement 5: Emacs Structural Editing Bindings
+
+**Objective:** As an Emacs user, I want C-c prefix keybindings for structural markdown operations (lists, blockquotes, links, headings), so that I can perform all common markdown editing without leaving Emacs-style key sequences.
+
+#### Acceptance Criteria
+
+1. While Emacs keymap mode is active, when the user types `C-c C-s q`, the Editor shall insert or toggle a blockquote prefix (`>`) on the current line, consistent with markdown-mode `markdown-insert-blockquote`.
+2. While Emacs keymap mode is active, when the user types `C-c C-l`, the Editor shall insert a markdown link template (`[]()`) around the selection or at the cursor, consistent with markdown-mode `markdown-insert-link`.
+3. While Emacs keymap mode is active, when the user types `C-c C-s -`, the Editor shall insert a horizontal rule (`---`) at the current line, consistent with markdown-mode `markdown-insert-hr`.
+4. While Emacs keymap mode is active, when the user types `C-c C-s h`, the Editor shall insert an ATX heading with auto-determined level based on context, consistent with markdown-mode `markdown-insert-header-dwim`.
+5. While Emacs keymap mode is active, when the user types `C-c C-s 1` through `C-c C-s 6`, the Editor shall insert or replace the corresponding heading level (`#` through `######`) at the beginning of the current line.
+6. While Emacs keymap mode is active, when the user types `C-c C-j`, the Editor shall insert a new list item appropriate to the current list context (bullet or numbered).
+7. While Emacs keymap mode is active, when the user types `C-c C-s C`, the Editor shall insert a GFM-style fenced code block with language specifier prompt.
+
+### Requirement 6: Emacs Save Binding
+
+**Objective:** As an Emacs user, I want `C-x C-s` to save the page, so that the standard Emacs save keybinding works in the GROWI editor.
+
+#### Acceptance Criteria
+
+1. While Emacs keymap mode is active, when the user types `C-x C-s`, the Editor shall invoke the save action (same as the existing onSave callback used by Vim's `:w`).
+2. If no save callback is provided, the Editor shall silently ignore `C-x C-s` without error.
+
+### Requirement 7: Vim Keymap Module Consistency
+
+**Objective:** As a developer, I want the Vim keymap module to follow the same structural pattern as other keymap modules, so that the codebase is consistent.
+
+#### Acceptance Criteria
+
+1. The Vim keymap module shall follow the same factory interface pattern as all other keymap modules.
+2. The Vim keymap module shall encapsulate its top-level side effects (e.g., `Vim.map` calls) within the factory function rather than at module scope.
+
+### Requirement 8: Keymap Selection UI Consistency
+
+**Objective:** As a user, I want the keymap selector UI to accurately represent all available keymap modes, so that I can choose my preferred editing style.
+
+#### Acceptance Criteria
+
+1. The Keymap Selector shall display all registered keymap modes with appropriate labels and icons.
+2. When the user selects a keymap mode, the Editor shall switch to that mode without requiring a page reload.
+3. The Editor shall persist the selected keymap mode across sessions.
+
+### Requirement 9: Emacs Extended Markdown-Mode Bindings
+
+**Objective:** As an Emacs power user, I want additional markdown-mode keybindings for navigation, promotion/demotion, and advanced editing, so that the GROWI editor feels as close to native Emacs markdown-mode as possible.
+
+#### Acceptance Criteria
+
+1. While Emacs keymap mode is active, when the user types `C-c C--`, the Editor shall promote (outdent) the current element (heading level decrease or list outdent).
+2. While Emacs keymap mode is active, when the user types `C-c C-=`, the Editor shall demote (indent) the current element (heading level increase or list indent).
+3. While Emacs keymap mode is active, when the user types `C-c C-n` / `C-c C-p`, the Editor shall navigate to the next/previous heading.
+4. While Emacs keymap mode is active, when the user types `C-c C-f` / `C-c C-b`, the Editor shall navigate to the next/previous heading at the same level.
+5. While Emacs keymap mode is active, when the user types `C-c C-u`, the Editor shall navigate up to the parent heading.
+6. While Emacs keymap mode is active, when the user types `C-c C-k`, the Editor shall kill (delete) the element at point and add text content to the clipboard.
+7. While Emacs keymap mode is active, when the user types `C-c C-i`, the Editor shall insert a markdown image template (`![]()`).
+8. While Emacs keymap mode is active, when the user types `C-c C-s t`, the Editor shall insert a markdown table template.
+9. While Emacs keymap mode is active, when the user types `C-c C-s f`, the Editor shall insert a footnote marker and definition pair.

+ 118 - 0
.kiro/specs/editor-keymaps/research.md

@@ -0,0 +1,118 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: editor-keymaps
+- **Discovery Scope**: Extension (existing keymap system)
+- **Key Findings**:
+  - `@replit/codemirror-emacs` EmacsHandler supports multi-stroke key chains natively via `bindKey` and `addCommands`; no C-x C-s save built-in
+  - Existing `toggleMarkdownSymbol` in emacs.ts duplicates logic from `useInsertMarkdownElements` hook; both perform wrap/unwrap but with different APIs (EditorView direct vs hook-based)
+  - Current dispatcher (`getKeymap`) mixes mode-specific concerns (inline vscode/default construction, precedence branching in consumer)
+
+## Research Log
+
+### @replit/codemirror-emacs API Surface
+- **Context**: Need to understand what multi-stroke bindings are possible for C-c C-s, C-c C-, C-x C-s
+- **Sources Consulted**: `node_modules/@replit/codemirror-emacs/dist/index.d.ts`, compiled source
+- **Findings**:
+  - `EmacsHandler.bindKey(keyGroup: string, command: any)` supports pipe-separated alternatives and multi-stroke chains
+  - `EmacsHandler.addCommands(commands: object)` registers named commands; command receives `{ view: EditorView }`
+  - Key chain state tracked via `$data.keyChain`; intermediate keys store `null` in binding map
+  - Built-in bindings include C-k (kill line), C-w (kill region), C-y (yank), C-Space (set mark), but NOT C-x C-s
+  - Package version: 6.1.0
+- **Implications**: C-x C-s must be explicitly registered. All proposed Emacs bindings are achievable via the existing API.
+
+### Markdown Symbol Toggle Duplication
+- **Context**: emacs.ts has `toggleMarkdownSymbol(view, prefix, suffix)` while editor-shortcuts use `useInsertMarkdownElements` hook
+- **Sources Consulted**: `insert-markdown-elements.ts`, `emacs.ts`, `generate-add-markdown-symbol-command.ts`
+- **Findings**:
+  - `useInsertMarkdownElements` is a React hook returning `(prefix: string, suffix: string) => void`
+  - `toggleMarkdownSymbol` is a pure function taking `(view: EditorView, prefix: string, suffix: string) => void`
+  - Both implement wrap/unwrap toggle logic but with slightly different selection handling
+  - Emacs commands receive handler object with `view` property, not a React context
+  - Hook-based approach cannot be used inside `EmacsHandler.addCommands` since it's not a React component
+- **Implications**: Need a shared pure function (non-hook) that both the hook and Emacs commands can use. The hook wraps the pure function; Emacs calls it directly.
+
+### Prefix Insertion for Structural Bindings
+- **Context**: Need to support blockquote, list, heading insertion from Emacs commands
+- **Sources Consulted**: `insert-prefix.ts`, `insert-blockquote.ts`, `insert-numbered-list.ts`
+- **Findings**:
+  - `useInsertPrefix` is also a React hook: `(prefix: string, noSpaceIfPrefixExists?: boolean) => void`
+  - Handles multi-line selections, indentation-aware
+  - Same constraint: cannot be used inside EmacsHandler commands directly
+- **Implications**: Need pure function extraction for prefix operations too, callable with EditorView directly.
+
+### Precedence Architecture
+- **Context**: Emacs/Vim use Prec.high, default/vscode use Prec.low; currently branched in consumer
+- **Sources Consulted**: `use-editor-settings.ts` lines 87-99
+- **Findings**:
+  - Emacs/Vim use ViewPlugin DOM event handlers intercepting at keydown level
+  - Must run before CodeMirror's keymap handler to avoid Mac Ctrl-* and completionKeymap conflicts
+  - VSCode/default use `keymap.of()` which integrates with CodeMirror's handler directly
+- **Implications**: Precedence is inherent to the keymap type. Encapsulating it in the module return value eliminates consumer branching.
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| A: Return-object factory | Each module returns `{ extension, precedence, overrides }` | Clean interface, no consumer branching | Slightly more complex return type | Preferred |
+| B: Pre-wrapped extension | Each module returns `Prec.high(extension)` directly | Simplest consumer code | Consumer loses control over precedence | Less flexible |
+| C: Config registry | Central registry maps mode → config | Extensible | Over-engineering for 4 modes | Rejected |
+
+## Design Decisions
+
+### Decision: Pure Function Extraction for Markdown Operations
+- **Context**: Emacs commands need markdown toggle/prefix but can't use React hooks
+- **Alternatives Considered**:
+  1. Extract pure functions from hooks, hooks become thin wrappers
+  2. Create entirely new utility functions for Emacs
+  3. Use CodeMirror commands directly in Emacs module
+- **Selected Approach**: Option 1 — Extract pure functions, hooks wrap them
+- **Rationale**: Eliminates duplication, both hooks and Emacs commands share the same logic
+- **Trade-offs**: Slight refactoring of existing hooks, but no behavioral change
+- **Follow-up**: Verify existing tests still pass after extraction
+
+### Decision: Factory Return Object Pattern
+- **Context**: Need to encapsulate precedence and override declarations per keymap
+- **Alternatives Considered**:
+  1. Return `{ extension, precedence, overrides }` object
+  2. Return pre-wrapped extension with separate metadata query
+- **Selected Approach**: Option 1 — Structured return object
+- **Rationale**: Single source of truth per keymap; consumer code becomes a simple loop
+- **Trade-offs**: Breaking change to getKeymap interface, but internal-only API
+
+### Decision: Override Categories for Shortcut Exclusion
+- **Context**: Need to replace `if (keymapModeName === 'emacs')` hard-coding
+- **Selected Approach**: Each keymap declares `overrides: ShortcutCategory[]` where categories are `'formatting' | 'navigation' | 'structural'`
+- **Rationale**: New keymaps can declare their overrides without modifying shortcut registration code
+- **Binding Mechanism**: `CategorizedKeyBindings` wrapper type groups `KeyBinding[]` with a `category` field, allowing `useEditorShortcuts` to filter by category match against overrides
+
+### Decision: Emacs Submodule Split
+- **Context**: emacs.ts accumulates 19+ commands spanning formatting, structural, navigation, and save — low cohesion
+- **Alternatives Considered**:
+  1. Single file with sections (current approach)
+  2. Split into `emacs/` directory with submodules per responsibility
+  3. Split by binding prefix (C-c C-s vs C-c C-)
+- **Selected Approach**: Option 2 — submodules by responsibility (formatting, structural, navigation)
+- **Rationale**: Each submodule has a single reason to change. Adding a new heading command only touches structural.ts. Adding navigation only touches navigation.ts.
+- **Trade-offs**: More files, but each is small (<80 lines) and focused
+
+### Decision: Relocate editor-shortcuts to services-internal
+- **Context**: `editor-shortcuts/` is currently under `services/use-codemirror-editor/utils/` (public layer) but is never exported — only consumed by `stores/use-editor-shortcuts.ts`
+- **Alternatives Considered**:
+  1. Keep in services/, add explicit non-export marker
+  2. Move to services-internal/editor-shortcuts/
+  3. Inline into stores/use-editor-shortcuts.ts
+- **Selected Approach**: Option 2 — move to services-internal/
+- **Rationale**: Aligns actual visibility with directory convention. services/ = public API, services-internal/ = internal only. The shortcut definitions are internal implementation details that should not be importable by external consumers.
+- **Trade-offs**: Requires updating import paths in use-editor-shortcuts.ts and any internal consumers
+- **Follow-up**: Verify no external package imports from this path
+
+## Risks & Mitigations
+- EmacsHandler.addCommands is called at module load time (static method); ensure idempotency if module is re-imported → Mitigation: guard with registration flag
+- Multi-stroke key chains may conflict with browser shortcuts on some platforms → Mitigation: Test on Mac/Windows/Linux; C-c C-s prefix is safe since C-c alone is intercepted by Emacs plugin
+- Pure function extraction may subtly change selection behavior → Mitigation: Write unit tests for toggle behavior before refactoring
+
+## References
+- [@replit/codemirror-emacs](https://github.com/nicknisi/replit-codemirror-emacs) — v6.1.0, EmacsHandler API
+- [jrblevin/markdown-mode](https://github.com/jrblevin/markdown-mode) — Reference for Emacs markdown-mode keybindings
+- [CodeMirror 6 Keymap API](https://codemirror.net/docs/ref/#view.keymap) — Precedence and extension system

+ 22 - 0
.kiro/specs/editor-keymaps/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "editor-keymaps",
+  "created_at": "2026-04-08T00:00:00.000Z",
+  "updated_at": "2026-04-08T00:00:00.000Z",
+  "language": "en",
+  "phase": "tasks-generated",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 147 - 0
.kiro/specs/editor-keymaps/tasks.md

@@ -0,0 +1,147 @@
+# Implementation Plan
+
+- [x] 1. Extract shared markdown utility functions
+- [x] 1.1 Create the toggle markdown symbol utility
+  - Extract the inline markdown wrap/unwrap logic from the current Emacs keymap module into a standalone pure function
+  - Handle three cases: wrap selection, unwrap existing symbols, and insert empty symbols with cursor positioning
+  - Ensure no React or hook dependencies — pure CodeMirror state/view operations only
+  - _Requirements: 2.1, 2.3_
+
+- [x] 1.2 (P) Create the line prefix utility
+  - Extract line-prefix insertion logic into a standalone pure function alongside the toggle utility
+  - Support single-line and multi-line selections, toggle-off when all lines already have the prefix
+  - _Requirements: 2.1_
+
+- [x] 1.3 Rewire existing public hooks to delegate to the new shared utilities
+  - Update the insert-markdown-elements hook to become a thin wrapper calling the shared toggle function
+  - Update the insert-prefix hook to delegate to the shared line-prefix function
+  - Verify that existing editor behavior (bold, italic, etc. via toolbar/shortcuts) remains unchanged
+  - _Requirements: 2.2, 2.3_
+
+- [x] 2. Define keymap type system and refactor the dispatcher
+- [x] 2.1 Define the keymap result interface, factory type, and shortcut category types
+  - Introduce a structured return type that bundles extension, precedence wrapper, and override category declarations
+  - Define the shortcut category union type and the categorized key-bindings grouping type
+  - Place all types in a dedicated types module within the keymaps directory
+  - _Requirements: 1.2, 1.4, 3.1_
+
+- [x] 2.2 Simplify the keymap dispatcher to a thin router
+  - Remove all inline keymap construction logic (default and vscode mode handling) from the dispatcher
+  - Replace with a simple switch that delegates to each mode's factory function
+  - Ensure the dispatcher returns the structured keymap result to callers
+  - _Requirements: 1.2, 1.3_
+
+- [x] 3. Create dedicated keymap modules for each mode
+- [x] 3.1 (P) Create the default keymap module
+  - Implement as an async factory returning the standard CodeMirror default keymap with low precedence and no overrides
+  - _Requirements: 1.1_
+
+- [x] 3.2 (P) Create the VSCode keymap module
+  - Implement as an async factory returning the VSCode keymap extension with low precedence and no overrides
+  - _Requirements: 1.1_
+
+- [x] 3.3 Refactor the Vim keymap module for structural consistency
+  - Move top-level side effects (key mappings like jj/jk escape, :w ex-command) inside the factory function
+  - Add an idempotency guard to prevent duplicate registration on re-import
+  - Return high precedence and empty overrides (Vim uses its own modal system)
+  - Accept the optional onSave callback and register `:w` ex-command when provided
+  - _Requirements: 1.1, 7.1, 7.2_
+
+- [x] 4. Build the Emacs keymap module with formatting submodule
+- [x] 4.1 Create the Emacs module structure and factory entry point
+  - Set up the Emacs subdirectory with an index module that dynamically imports the Emacs extension
+  - The factory composes all submodule registrations, registers save binding, and returns high precedence with formatting and structural overrides declared
+  - _Requirements: 1.1, 1.4_
+
+- [x] 4.2 Implement the formatting bindings submodule
+  - Register C-c C-s prefix bindings for bold, italic, inline code, strikethrough, and code block
+  - Delegate all formatting operations to the shared toggle-markdown-symbol utility
+  - Support both lowercase and uppercase variants where specified (bold: b/B, italic: i/I)
+  - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
+
+- [x] 5. Relocate editor shortcuts and introduce category-based grouping
+- [x] 5.1 Move the editor-shortcuts directory from the public services layer to services-internal
+  - Physically relocate the directory and update all import paths in the consuming store module (10 imports)
+  - Verify build passes after relocation
+  - _Requirements: 3.2_
+
+- [x] 5.2 Wrap each shortcut group with categorized key-bindings metadata
+  - Group formatting shortcuts (bold, italic, strikethrough, code) under the formatting category
+  - Group structural shortcuts (numbered list, bullet list, blockquote, link) under the structural category
+  - Group always-on shortcuts (multi-cursor) with null category so they are never excluded
+  - _Requirements: 3.2, 3.3_
+
+- [x] 6. Refactor store layer for data-driven shortcut registration
+- [x] 6.1 Update the editor shortcuts store to use category-based exclusion
+  - Replace the hard-coded emacs mode check with data-driven filtering using the override categories from the keymap result
+  - Change the parameter from keymap mode name to an array of shortcut categories to exclude
+  - Filter categorized binding groups: include groups with null category always, exclude groups whose category appears in the overrides
+  - _Requirements: 3.1, 3.2, 3.3_
+
+- [x] 6.2 Simplify the editor settings store to use keymap result metadata
+  - Remove the standalone precedence-determination function
+  - Apply precedence directly from the keymap result's encapsulated precedence wrapper
+  - Pass the keymap result's override declarations to the editor shortcuts store
+  - _Requirements: 1.4_
+
+- [x] 7. Implement Emacs structural editing bindings
+- [x] 7.1 (P) Implement blockquote, link, and horizontal rule bindings
+  - Register C-c C-s q for blockquote toggle using the shared line-prefix utility
+  - Register C-c C-l for markdown link insertion using the shared toggle utility
+  - Register C-c C-s - for horizontal rule insertion
+  - _Requirements: 5.1, 5.2, 5.3_
+
+- [x] 7.2 (P) Implement heading bindings
+  - Register C-c C-s h for auto-determined heading level insertion
+  - Register C-c C-s 1 through C-c C-s 6 for explicit heading level insertion using the line-prefix utility
+  - _Requirements: 5.4, 5.5_
+
+- [x] 7.3 (P) Implement list item and fenced code block bindings
+  - Register C-c C-j for context-aware new list item insertion (detect bullet vs numbered from current context)
+  - Register C-c C-s C (shift-c) for GFM fenced code block insertion
+  - _Requirements: 5.6, 5.7_
+
+- [x] 8. Implement Emacs save binding
+  - Register C-x C-s as a two-stroke key sequence that invokes the onSave callback passed to the Emacs factory
+  - Silently ignore the binding when no save callback is provided
+  - Verify the same save mechanism used by Vim's :w command
+  - _Requirements: 6.1, 6.2_
+
+- [x] 9. Implement Emacs extended navigation and editing bindings
+- [x] 9.1 (P) Implement heading navigation bindings
+  - Register C-c C-n / C-c C-p to navigate to the next/previous heading at any level
+  - Register C-c C-f / C-c C-b to navigate to the next/previous heading at the same level
+  - Register C-c C-u to navigate up to the parent heading
+  - Use regex-based heading detection to scan document structure
+  - _Requirements: 9.3, 9.4, 9.5_
+
+- [x] 9.2 (P) Implement promotion and demotion bindings
+  - Register C-c C-- to promote (outdent) the current element: decrease heading level or outdent list item
+  - Register C-c C-= to demote (indent) the current element: increase heading level or indent list item
+  - Detect element type at cursor to apply the appropriate operation
+  - _Requirements: 9.1, 9.2_
+
+- [x] 9.3 (P) Implement kill, image, table, and footnote bindings
+  - Register C-c C-k to kill (delete) the element at point and copy its text content to the clipboard
+  - Register C-c C-i to insert a markdown image template
+  - Register C-c C-s t to insert a markdown table template
+  - Register C-c C-s f to insert a footnote marker and definition pair
+  - _Requirements: 9.6, 9.7, 9.8, 9.9_
+
+- [x] 10. Integration verification and UI consistency check
+- [x] 10.1 Verify keymap selection UI displays all modes correctly
+  - Confirm the keymap selector shows all four modes with appropriate labels
+  - Verify switching between modes applies immediately without page reload
+  - Confirm the selected mode persists across sessions via existing storage mechanism
+  - _Requirements: 8.1, 8.2, 8.3_
+
+- [x] 10.2 Add integration tests for keymap mode switching and shortcut exclusion
+  - Test that formatting shortcuts are excluded in Emacs mode but present in default mode
+  - Test that mode switching preserves document content
+  - Test that C-x C-s triggers save in Emacs mode and :w triggers save in Vim mode
+  - _Requirements: 1.4, 3.2, 6.1_
+
+- [x] 10.3 (P) Add E2E tests for Emacs keybindings
+  - Extend the existing Playwright editor test pattern to cover Emacs formatting bindings (C-c C-s b for bold, etc.)
+  - Cover at least one structural binding (C-c C-l for link) and one navigation binding (C-c C-n for next heading)
+  - _Requirements: 4.1, 5.2, 9.3_

+ 23 - 149
.kiro/specs/migrate-logger-to-pino/design.md → .kiro/specs/growi-logger/design.md

@@ -1,43 +1,43 @@
-# Design Document: migrate-logger-to-pino
+# Design Document: growi-logger
 
 ## Overview
 
-**Purpose**: This feature migrates GROWI's logging infrastructure from bunyan (with the custom `universal-bunyan` wrapper) to pino, delivering faster structured logging with a smaller dependency footprint.
+**Purpose**: `@growi/logger` is the shared logging infrastructure for the GROWI monorepo, providing namespace-based level control, platform detection (Node.js/browser), and Express HTTP middleware — built on pino.
 
-**Users**: All GROWI developers (logger consumers), operators (log level configuration), and the CI/CD pipeline (dependency management).
+**Users**: All GROWI developers (logger consumers), operators (log level configuration), and the CI/CD pipeline.
 
-**Impact**: Replaces 7 logging-related packages (`bunyan`, `universal-bunyan`, `bunyan-format`, `express-bunyan-logger`, `morgan`, `browser-bunyan`, `@browser-bunyan/console-formatted-stream`) with 3 (`pino`, `pino-pretty`, `pino-http`) plus a new shared package `@growi/logger`. Consumer applications import only `@growi/logger`; `pino-http` is encapsulated within the package.
+**Scope**: All GROWI applications (`apps/app`, `apps/slackbot-proxy`) and packages (`packages/slack`, `packages/remark-attachment-refs`, `packages/remark-lsx`) import from `@growi/logger` as the single logging entry point. Consumer applications do not import pino or pino-http directly.
 
 ### Goals
-- Replace bunyan with pino across all apps and packages without functional degradation
-- Preserve namespace-based log level control (config files + env var overrides)
-- Eliminate morgan by consolidating HTTP logging into pino-http
+- Provide namespace-based log level control via config objects and environment variable overrides
+- Consolidate HTTP request logging under `createHttpLoggerMiddleware()` (pino-http encapsulated)
 - Maintain OpenTelemetry diagnostic logger integration
-- Provide a shared `@growi/logger` package as the single logging entry point
+- Serve as the single `@growi/logger` entry point for all monorepo consumers
+- Preserve pino's worker-thread performance model (single Worker thread, child loggers)
 
 ### Non-Goals
-- Changing log output semantics (field names, message format) beyond what pino naturally produces
 - Adding new logging capabilities (structured context propagation, remote log shipping)
-- Migrating to pino v10 (deferred until OTel instrumentation supports it)
 - Changing the namespace naming convention (e.g., `growi:service:page`)
+- Publishing `@growi/logger` to npm (private package, monorepo-internal only)
+- Migrating to pino v10 (blocked on `@opentelemetry/instrumentation-pino` v10 support)
 
 ## Architecture
 
-### Existing Architecture Analysis
+### Architecture Overview
 
-The current logging stack has these layers:
+`@growi/logger` is organized into these layers:
 
-1. **universal-bunyan** — custom wrapper providing: namespace-based level control via config + env vars, platform detection (Node.js/browser), stream selection (bunyan-format for Node.js, ConsoleFormattedStream for browser), logger caching
-2. **Per-app loggerFactory** — thin wrapper that loads dev/prod config and delegates to universal-bunyan
-3. **bunyan / browser-bunyan** — underlying logger implementations
-4. **express-bunyan-logger / morgan** — HTTP request logging middleware
+1. **LoggerFactory** — creates and caches namespace-bound pino child loggers; `initializeLoggerFactory` spawns one Worker thread; `loggerFactory(name)` returns `rootLogger.child({ name })` with resolved level
+2. **LevelResolver + EnvVarParser** — resolve log level from config patterns and env var overrides via minimatch glob matching
+3. **TransportFactory** — produces pino transport config for Node.js (dev: bunyan-format, prod+FORMAT_NODE_LOG: pino-pretty singleLine, prod: raw JSON) and browser (console)
+4. **HttpLoggerFactory** — encapsulates pino-http as `createHttpLoggerMiddleware()`; dev-mode morgan-like formatting dynamically imported from `src/dev/`
 
-Key patterns to preserve:
-- `loggerFactory(name: string): Logger` as the sole logger creation API
+Key invariants:
+- `loggerFactory(name: string): Logger<string>` as the sole logger creation API
 - Hierarchical colon-delimited namespaces with glob pattern matching
-- Environment variables (`DEBUG`, `TRACE`, etc.) overriding config file levels
-- Dev: human-readable output; Prod: JSON output (toggleable via `FORMAT_NODE_LOG`)
-- Browser: console output with error-level default in production
+- `pino.transport()` called **once** in `initializeLoggerFactory`; all namespace loggers share the Worker thread
+- Dev-only modules (`src/dev/`) are never statically imported in production paths
+- Browser-unsafe modules (pino-http) are imported lazily inside function bodies
 
 ### Architecture Pattern & Boundary Map
 
@@ -88,7 +88,7 @@ graph TB
 ```
 
 **Architecture Integration**:
-- Selected pattern: Wrapper package (`@growi/logger`) encapsulating pino configuration — mirrors universal-bunyan's role
+- `@growi/logger` wraps pino with namespace-level control, transport setup, and HTTP middleware — the single logging entry point for all monorepo consumers
 - Domain boundary: `@growi/logger` owns all logger creation, level resolution, and transport setup; consumer apps only call `loggerFactory(name)`
 - Existing patterns preserved: factory function signature, namespace conventions, config file structure
 - New components: `LevelResolver` (namespace-to-level matching), `TransportFactory` (dev/prod stream setup), `EnvVarParser` (env variable parsing)
@@ -173,8 +173,7 @@ flowchart TD
 | 6.1–6.4 | HTTP request logging | HttpLoggerMiddleware | `createHttpLogger()` | — |
 | 7.1–7.3 | OpenTelemetry integration | DiagLoggerPinoAdapter | `DiagLogger` interface | — |
 | 8.1–8.5 | Multi-app consistency | @growi/logger package | Package exports | — |
-| 9.1–9.3 | Dependency cleanup | — (removal task) | — | — |
-| 10.1–10.3 | Backward-compatible API | LoggerFactory | `Logger` type export | — |
+| 10.1–10.3 | Pino logger type export | LoggerFactory | `Logger<string>` export | — |
 | 11.1–11.4 | Pino performance preservation | LoggerFactory | `initializeLoggerFactory`, shared root logger | Logger Creation |
 | 12.1–12.6 | Bunyan-like output format | BunyanFormatTransport, TransportFactory | Custom transport target | Logger Creation |
 | 13.1–13.5 | HTTP logger encapsulation | HttpLoggerFactory | `createHttpLoggerMiddleware()` | — |
@@ -543,128 +542,3 @@ Logging infrastructure must be resilient — a logger failure must never crash t
 - Logger initialization errors are written to `process.stderr` directly (cannot use the logger itself)
 - No additional monitoring infrastructure required — this is the monitoring infrastructure
 
-## Addendum: Formatting Improvements (Post-Migration)
-
-> Added 2026-03-30. The core migration is complete. This section covers log output readability improvements based on operator feedback.
-
-### Background
-
-- Morgan was used in dev because bunyan's express logging was too verbose
-- Morgan's one-liner format (`GET /path 200 12ms`) was valued for readability
-- `FORMAT_NODE_LOG=true` should produce concise one-liner logs suitable for quick-glance monitoring
-- Production default should remain structured JSON (already working via `.env.production`)
-
-### Gap Summary
-
-| Gap | Issue | Resolution |
-|-----|-------|------------|
-| A | `singleLine: false` in prod FORMAT_NODE_LOG path | Change to `singleLine: true` |
-| B | `FORMAT_NODE_LOG` defaults to formatted when unset | Defer to separate PR (`.env.production` handles this) |
-| C | pino-http uses default verbose messages | Add `customSuccessMessage` / `customErrorMessage` / `customLogLevel` |
-| D | Dev and prod pino-pretty configs identical | Differentiate via `singleLine` |
-
-### Change 1: TransportFactory — Differentiated `singleLine`
-
-**File**: `packages/logger/src/transport-factory.ts`
-
-Current production + FORMAT_NODE_LOG branch uses `singleLine: false`. Change to `singleLine: true`:
-
-```
-Dev:                    singleLine: false  (unchanged — full context)
-Prod + FORMAT_NODE_LOG: singleLine: true   (concise one-liners)
-Prod default:           raw JSON           (unchanged)
-```
-
-The dev branch remains multi-line so developers see full object context. The production formatted path becomes single-line for operator readability.
-
-### Change 2: HttpLoggerMiddleware — Custom Message Format
-
-**Files**: `apps/app/src/server/crowi/index.ts`, `apps/slackbot-proxy/src/Server.ts`
-
-Add pino-http message customization to produce morgan-like output:
-
-```typescript
-const customSuccessMessage: PinoHttpOptions['customSuccessMessage'] = (req, res, responseTime) => {
-  return `${req.method} ${req.url} ${res.statusCode} - ${Math.round(responseTime)}ms`;
-};
-
-const customErrorMessage: PinoHttpOptions['customErrorMessage'] = (req, res, error) => {
-  return `${req.method} ${req.url} ${res.statusCode} - ${error.message}`;
-};
-
-const customLogLevel: PinoHttpOptions['customLogLevel'] = (_req, res, error) => {
-  if (error != null || res.statusCode >= 500) return 'error';
-  if (res.statusCode >= 400) return 'warn';
-  return 'info';
-};
-```
-
-### Output Examples (Updated with dev-only bunyan-like format)
-
-**Dev** (bunyan-format transport + morgan-like HTTP messages):
-```
-10:06:30.419Z  INFO express: GET /page/path 200 - 12ms
-    req: {"method":"GET","url":"/page/path"}
-    res: {"statusCode":200}
-```
-
-**Prod + FORMAT_NODE_LOG=true** (standard pino-pretty, default pino-http messages):
-```
-[2026-03-30 12:00:00.000] INFO (express): request completed
-```
-
-**Prod default** (JSON, default pino-http messages):
-```json
-{"level":30,"time":1711792800000,"name":"express","msg":"request completed","req":{"method":"GET","url":"/page/path"},"res":{"statusCode":200},"responseTime":12}
-```
-
-### Testing
-
-- `transport-factory.spec.ts`: Verify transport target contains `bunyan-format` (not pino-pretty directly); dev transport passes no options (singleLine handled inside bunyan-format); prod + FORMAT_NODE_LOG returns pino-pretty with `singleLine: true`
-- `bunyan-format.spec.ts`: Verify transport module produces `HH:mm:ss.SSSZ LEVEL name: message` format; verify req/res are excluded from output
-- `http-logger.spec.ts`: Verify `createHttpLoggerMiddleware` returns middleware, applies morganLikeFormatOptions in dev, passes autoLogging options
-- `morgan-like-format-options.spec.ts`: Verify message formats using `strip()` to remove ANSI codes before assertion; verify customLogLevel returns correct levels for 2xx/4xx/5xx
-
----
-
-## Addendum: HTTP Logger Encapsulation (Post-Migration)
-
-> Added 2026-04-02. Moves pino-http usage from consumer apps into @growi/logger.
-
-### Background
-
-- Consumer apps (`apps/app`, `apps/slackbot-proxy`) currently import `pino-http` directly
-- This leaks implementation details and requires each app to configure morgan-like format options
-- Encapsulating in `@growi/logger` provides a single configuration point and cleaner dependency graph
-
-### Changes
-
-1. **New file**: `packages/logger/src/http-logger.ts` — exports `createHttpLoggerMiddleware(options)`
-2. **Package.json**: Add `pino-http` to `@growi/logger` dependencies
-3. **apps/app**: Replace direct `pino-http` import with `createHttpLoggerMiddleware` from `@growi/logger`
-4. **apps/slackbot-proxy**: Same as apps/app
-5. **Cleanup**: Remove `pino-http` from apps' direct dependencies (keep in @growi/logger)
-
----
-
-## Addendum: Dev-Only Module Isolation and Browser Compatibility (Post-Migration)
-
-> Added 2026-04-06. Restructures dev-only modules and fixes browser bundle compatibility.
-
-### Background
-
-- `bunyan-format` and `morgan-like-format-options` were mixed with production modules at the `src/` root level
-- `pino-http` imported at the module top-level caused browser bundle errors (Turbopack: `TypeError: __turbopack_context__.r(...).symbols is undefined`) when `@growi/logger` was imported by shared page code
-- HTTP request logs in dev were verbose (multi-line `req`/`res` JSON objects)
-- HTTP status codes in dev lacked visual differentiation
-
-### Changes
-
-1. **`src/dev/` directory**: All dev-only modules moved under `src/dev/`
-   - `src/transports/bunyan-format.ts` → `src/dev/bunyan-format.ts`
-   - `src/morgan-like-format-options.ts` → `src/dev/morgan-like-format-options.ts`
-   - `src/transports/` directory removed
-2. **`index.ts`**: Removed static `export { morganLikeFormatOptions }` — dev-only module must not appear in production-facing package exports
-3. **`http-logger.ts`**: `pino-http` import moved from module top-level into the async function body (`const { default: pinoHttp } = await import('pino-http')`) — prevents browser bundlers from including the Node.js-only package
-4. **`bunyan-format.ts`**: `ignore` extended to `'pid,hostname,name,req,res,responseTime'` — suppresses verbose pino-http req/res objects; morgan-like `customSuccessMessage` already provides all relevant HTTP metadata on one line
-5. **`morgan-like-format-options.ts`**: ANSI color codes added for status code (2xx=green, 3xx=cyan, 4xx=yellow, 5xx=red) and dim response time; `NO_COLOR` env var respected

+ 79 - 0
.kiro/specs/growi-logger/requirements.md

@@ -0,0 +1,79 @@
+# Requirements Document
+
+## Introduction
+
+`@growi/logger` is the shared logging package for the GROWI monorepo, wrapping pino with namespace-based level control, platform detection (Node.js/browser), and Express HTTP middleware. All GROWI applications and packages import from `@growi/logger` as the single logging entry point.
+
+## Requirements
+
+### Requirement 1: Logger Factory with Namespace Support
+
+**Objective:** Provide `loggerFactory(name: string)` returning a pino logger bound to the given namespace, so developers can identify the source of log messages and control granularity per module.
+
+**Summary**: `loggerFactory(name)` returns a cached pino child logger for the namespace — same namespace always returns the same instance. Namespaces follow colon-delimited hierarchical convention (e.g., `growi:service:page`). The logger exposes `.info()`, `.debug()`, `.warn()`, `.error()`, `.trace()`, and `.fatal()` methods compatible with all existing call sites.
+
+### Requirement 2: Namespace-Based Log Level Configuration via Config Files
+
+**Objective:** Load per-namespace log levels from configuration objects (separate for dev and prod), allowing fine-tuned verbosity per module without restart.
+
+**Summary**: Accepts a `LoggerConfig` object mapping namespace patterns to log levels (e.g., `{ 'growi:service:*': 'debug', 'default': 'info' }`). Uses minimatch-compatible glob patterns. When no pattern matches, falls back to the `default` level. Per-app loggerFactory wrappers load dev/prod config files and pass the result to `initializeLoggerFactory`.
+
+### Requirement 3: Environment Variable-Based Log Level Override
+
+**Objective:** Override log levels at runtime via environment variables, enabling debug/trace logging for specific namespaces without modifying config files.
+
+**Summary**: Reads `DEBUG`, `TRACE`, `INFO`, `WARN`, `ERROR`, and `FATAL` environment variables. Each supports comma-separated namespace patterns with glob wildcards (e.g., `DEBUG=growi:routes:*,growi:service:page`). Environment variable matches take precedence over config file entries.
+
+### Requirement 4: Platform-Aware Logger (Node.js and Browser)
+
+**Objective:** Work seamlessly in both Node.js and browser environments using the same `loggerFactory` import.
+
+**Summary**: Detects runtime environment via `typeof window` check and applies appropriate transport. In browsers, outputs to the developer console; defaults to `error` level in production to minimize console noise. In Node.js, uses transport-based formatting as defined in Requirement 5.
+
+### Requirement 5: Output Formatting (Development vs Production)
+
+**Objective:** Provide distinct log output formats for development (human-readable) and production (structured JSON).
+
+**Summary**: Development uses the bunyan-format custom transport (`HH:mm:ss.SSSZ LEVEL name: message` format, colorized). Production defaults to raw JSON. When `FORMAT_NODE_LOG` is set, production uses standard pino-pretty with `singleLine: true`. The logger namespace is included in all output.
+
+### Requirement 6: HTTP Request Logging
+
+**Objective:** Provide Express HTTP request logging via `createHttpLoggerMiddleware()`, encapsulating pino-http so consumer apps do not depend on it directly.
+
+**Summary**: `createHttpLoggerMiddleware(options?)` returns Express-compatible middleware. In development, applies morgan-like message formatting (method, URL, status, response time) via dynamic import of `src/dev/morgan-like-format-options.ts`. In production, uses pino-http's default format. Static file paths can be excluded via `autoLogging.ignore`.
+
+### Requirement 7: OpenTelemetry Integration
+
+**Objective:** Integrate with OpenTelemetry diagnostics so observability tooling continues to function.
+
+**Summary**: `DiagLoggerPinoAdapter` in apps/app wraps pino as an OTel `DiagLogger`, mapping `verbose` to pino `trace`. The OTel SDK configuration disables `@opentelemetry/instrumentation-pino`.
+
+### Requirement 8: Multi-App Consistency
+
+**Objective:** All GROWI monorepo applications use the same pino-based logging solution from `@growi/logger`.
+
+**Summary**: `apps/app`, `apps/slackbot-proxy`, `packages/slack`, `packages/remark-attachment-refs`, and `packages/remark-lsx` all import from `@growi/logger` via `workspace:*`. The package is `"private": true` — monorepo-internal only, not published to npm.
+
+### Requirement 10: Pino Logger Type Export
+
+**Objective:** Export a TypeScript type for logger instances compatible with pino-http and other pino-ecosystem packages.
+
+**Summary**: `@growi/logger` exports `Logger<string>` (not the default `Logger<never>`) so the type is assignable to pino-http's `logger` option and other external APIs. Consumers type-annotate logger variables using this export without importing pino directly.
+
+### Requirement 11: Single Worker Thread Performance Model
+
+**Objective:** Honor pino's design philosophy of minimal main-thread overhead.
+
+**Summary**: `pino.transport()` is called exactly once in `initializeLoggerFactory()`. All namespace loggers are created via `rootLogger.child({ name })`, sharing the single Worker thread. The root logger level is `'trace'` so children can independently apply their resolved level. The Worker thread count never exceeds 1, regardless of namespace count.
+
+### Requirement 12: Bunyan-Like Output Format (Development Only)
+
+**Objective:** Provide human-readable log output in development mode matching the legacy bunyan-format "short" style.
+
+**Summary**: In development, each log line uses `HH:mm:ss.SSSZ LEVEL name: message` with 5-char right-aligned level labels and level-based colorization (cyan/green/yellow/red). Implemented as a custom pino transport at `src/dev/bunyan-format.ts` — only loaded in development. Standard pino-pretty is used for `FORMAT_NODE_LOG` in production. The `NO_COLOR` environment variable is respected.
+
+### Requirement 13: HTTP Logger Middleware Encapsulation
+
+**Objective:** Encapsulate pino-http within `@growi/logger` so consumer apps do not import pino-http directly.
+
+**Summary**: `createHttpLoggerMiddleware(options?)` is the sole HTTP logging API. `pino-http` is a dependency of `@growi/logger`, imported lazily inside the async function body (preventing browser bundle inclusion via Turbopack/webpack). Morgan-like formatting (`src/dev/morgan-like-format-options.ts`) is dynamically imported only in development. Status codes are colorized (2xx=green, 3xx=cyan, 4xx=yellow, 5xx=red) with `NO_COLOR` env var support.

+ 1 - 1
.kiro/specs/migrate-logger-to-pino/research.md → .kiro/specs/growi-logger/research.md

@@ -5,7 +5,7 @@
 ---
 
 ## Summary
-- **Feature**: `migrate-logger-to-pino`
+- **Feature**: `growi-logger`
 - **Discovery Scope**: Complex Integration
 - **Key Findings**:
   - Pino and bunyan share identical argument patterns (`logger.info(obj, msg)`) — no call-site changes needed

+ 2 - 2
.kiro/specs/migrate-logger-to-pino/spec.json → .kiro/specs/growi-logger/spec.json

@@ -1,7 +1,7 @@
 {
-  "feature_name": "migrate-logger-to-pino",
+  "feature_name": "growi-logger",
   "created_at": "2026-03-23T00:00:00.000Z",
-  "updated_at": "2026-04-06T00:00:00.000Z",
+  "updated_at": "2026-04-10T00:00:00.000Z",
   "language": "en",
   "phase": "implementation-complete",
   "cleanup_completed": true,

+ 18 - 0
.kiro/specs/growi-logger/tasks.md

@@ -0,0 +1,18 @@
+# Implementation History
+
+All tasks completed (2026-03-23 → 2026-04-06). This section records the implementation scope for future reference.
+
+- [x] 1. Scaffold `@growi/logger` shared package — package.json (pino v9.x, minimatch, pino-pretty peer), TypeScript ESM config, vitest setup, package entry points (main/types/browser)
+- [x] 2. Environment variable parsing and level resolution — `EnvVarParser` (reads DEBUG/TRACE/INFO/WARN/ERROR/FATAL), `LevelResolver` (minimatch glob matching, env-override precedence)
+- [x] 3. Transport factory — `TransportFactory` for Node.js dev (bunyan-format), prod+FORMAT_NODE_LOG (pino-pretty singleLine), prod default (raw JSON), and browser (console)
+- [x] 4. Logger factory — `initializeLoggerFactory` (spawns one Worker thread), `loggerFactory(name)` (child logger cache, level resolution)
+- [x] 5. Migrate shared packages — packages/slack, packages/remark-attachment-refs, packages/remark-lsx; fix pino-style call sites (object-first argument order)
+- [x] 6. Migrate apps/slackbot-proxy — logger factory, pino-http HTTP middleware, type imports, pino-style call sites
+- [x] 7. Migrate apps/app — logger factory, pino-http HTTP middleware, DiagLoggerPinoAdapter (OTel), bunyan type references
+- [x] 8. Remove all bunyan/morgan dependencies; verify no residual imports across monorepo
+- [x] 9. Full monorepo validation — lint, type-check, test, build for @growi/app, @growi/slackbot-proxy, @growi/logger
+- [x] 10. Differentiate pino-pretty `singleLine`: dev=false (multi-line context), prod+FORMAT_NODE_LOG=true (concise one-liners)
+- [x] 11. Morgan-like HTTP formatting — `customSuccessMessage`, `customErrorMessage`, `customLogLevel` in pino-http config
+- [x] 12. Bunyan-format custom transport (`src/dev/bunyan-format.ts`) — `HH:mm:ss.SSSZ LEVEL name: message` format, colorization, NO_COLOR support, pino.transport() worker thread
+- [x] 13. `createHttpLoggerMiddleware` — encapsulate pino-http in `@growi/logger`; move morgan-like options inside; add to @growi/logger deps
+- [x] 14. Dev-only module isolation (`src/dev/`) and browser bundle fix — lazy pino-http import, extended `ignore` field in bunyan-format

+ 0 - 156
.kiro/specs/migrate-logger-to-pino/requirements.md

@@ -1,156 +0,0 @@
-# Requirements Document
-
-## Introduction
-
-GROWI currently uses bunyan as its logging library, wrapped by the custom `universal-bunyan` package (developed by WeSeek). The system provides namespace-based hierarchical logging with environment variable-driven log level control, platform detection (Node.js/Browser), and different output formatting for development and production environments. Morgan is used for HTTP request logging in development mode while `express-bunyan-logger` handles production HTTP logging.
-
-This specification covers the complete migration from bunyan to pino, replacing `universal-bunyan` with an equivalent pino-based solution, and eliminating morgan by consolidating HTTP request logging under pino. The migration must preserve all existing functionality without degradation.
-
-### Current Components to Replace
-- `bunyan` → `pino`
-- `universal-bunyan` (custom) → pino-based equivalent (official packages preferred, custom wrapper where needed)
-- `bunyan-format` → pino transport equivalent (e.g., `pino-pretty`)
-- `express-bunyan-logger` → `pino-http` or equivalent
-- `morgan` (dev only) → consolidated into pino-http
-- `browser-bunyan` / `@browser-bunyan/console-formatted-stream` → pino browser mode or equivalent
-- `@types/bunyan` → pino's built-in types
-
-## Requirements
-
-### Requirement 1: Logger Factory with Namespace Support
-
-**Objective:** As a developer, I want to create loggers with hierarchical namespace identifiers (e.g., `growi:service:page`), so that I can identify the source of log messages and control granularity per module.
-
-#### Acceptance Criteria
-1. The Logger Factory shall provide a `loggerFactory(name: string)` function that returns a logger instance bound to the given namespace.
-2. When `loggerFactory` is called multiple times with the same namespace, the Logger Factory shall return the same cached logger instance.
-3. The Logger Factory shall support colon-delimited hierarchical namespaces (e.g., `growi:crowi`, `growi:routes:login`).
-4. The Logger Factory shall maintain API compatibility so that callers use `logger.info()`, `logger.debug()`, `logger.warn()`, `logger.error()`, `logger.trace()`, and `logger.fatal()` without changes to call sites.
-
-### Requirement 2: Namespace-Based Log Level Configuration via Config Files
-
-**Objective:** As a developer, I want to define per-namespace log levels in configuration files (separate for dev and prod), so that I can fine-tune verbosity for specific modules without restarting with different env vars.
-
-#### Acceptance Criteria
-1. The Logger Factory shall load a configuration object mapping namespace patterns to log levels (e.g., `{ 'growi:service:*': 'debug', 'default': 'info' }`).
-2. The Logger Factory shall select the dev or prod configuration based on the `NODE_ENV` environment variable.
-3. The Logger Factory shall support glob pattern matching (e.g., `growi:service:*`) for namespace-to-level mapping using minimatch-compatible syntax.
-4. When no specific namespace match exists, the Logger Factory shall fall back to the `default` level defined in the configuration.
-
-### Requirement 3: Environment Variable-Based Log Level Override
-
-**Objective:** As an operator, I want to override log levels at runtime via environment variables, so that I can enable debug/trace logging for specific namespaces without modifying code or config files.
-
-#### Acceptance Criteria
-1. The Logger Factory shall read the environment variables `DEBUG`, `TRACE`, `INFO`, `WARN`, `ERROR`, and `FATAL` to parse namespace patterns.
-2. When an environment variable (e.g., `DEBUG=growi:routes:*,growi:service:page`) is set, the Logger Factory shall apply the corresponding log level to all matching namespaces.
-3. When both a config file entry and an environment variable match the same namespace, the environment variable shall take precedence.
-4. The Logger Factory shall support comma-separated namespace patterns within a single environment variable value.
-5. The Logger Factory shall support glob wildcard patterns (e.g., `growi:*`) in environment variable values.
-
-### Requirement 4: Platform-Aware Logger (Node.js and Browser)
-
-**Objective:** As a developer, I want the logger to work seamlessly in both Node.js (server) and browser (client) environments, so that I can use the same `loggerFactory` import in universal/shared code.
-
-#### Acceptance Criteria
-1. The Logger Factory shall detect the runtime environment (Node.js vs browser) and instantiate the appropriate logger implementation.
-2. While running in a browser environment, the Logger Factory shall output logs to the browser's developer console with readable formatting.
-3. While running in a browser production environment, the Logger Factory shall default to `error` level to minimize console noise.
-4. While running in a Node.js environment, the Logger Factory shall output structured logs suitable for machine parsing or human-readable formatting depending on configuration.
-
-### Requirement 5: Output Formatting (Development vs Production)
-
-**Objective:** As a developer/operator, I want distinct log output formats for development and production, so that dev logs are human-readable while production logs are structured and parseable.
-
-#### Acceptance Criteria
-1. While `NODE_ENV` is not `production`, the Logger Factory shall output human-readable formatted logs (equivalent to bunyan-format `short` mode) using pino-pretty or an equivalent transport.
-2. While `NODE_ENV` is `production`, the Logger Factory shall output structured JSON logs by default.
-3. Where the `FORMAT_NODE_LOG` environment variable is set, the Logger Factory shall respect it to toggle between formatted and raw JSON output in production (formatted by default when `FORMAT_NODE_LOG` is unset or truthy).
-4. The Logger Factory shall include the logger namespace in all log output so that the source module is identifiable.
-
-### Requirement 6: HTTP Request Logging
-
-**Objective:** As a developer/operator, I want HTTP request logging integrated with pino, so that request/response metadata is captured in a consistent format alongside application logs, eliminating the need for morgan.
-
-#### Acceptance Criteria
-1. The GROWI Server shall log HTTP requests using `pino-http` or an equivalent pino-based middleware, replacing both `morgan` (dev) and `express-bunyan-logger` (prod).
-2. While in development mode, the HTTP Logger shall skip logging for Next.js static file requests (paths starting with `/_next/static/`).
-3. The HTTP Logger shall use a logger instance obtained from the Logger Factory with the namespace `express` (or equivalent) for consistency with existing log namespaces.
-4. The HTTP Logger shall include standard HTTP metadata (method, URL, status code, response time) in log entries.
-
-### Requirement 7: OpenTelemetry Integration
-
-**Objective:** As a developer, I want the pino-based logger to integrate with OpenTelemetry diagnostics, so that observability tooling continues to function after migration.
-
-#### Acceptance Criteria
-1. The OpenTelemetry DiagLogger adapter shall be updated to wrap pino instead of bunyan.
-2. The OpenTelemetry DiagLogger adapter shall map OpenTelemetry verbose level to pino trace level.
-3. The OpenTelemetry SDK configuration shall disable pino instrumentation if an equivalent auto-instrumentation exists (analogous to the current bunyan instrumentation disable).
-
-### Requirement 8: Multi-App Consistency
-
-**Objective:** As a developer, I want all GROWI monorepo applications to use the same pino-based logging solution, so that logging behavior and configuration are consistent across the platform.
-
-#### Acceptance Criteria
-1. The `apps/app` application shall use the pino-based Logger Factory.
-2. The `apps/slackbot-proxy` application shall use the pino-based Logger Factory.
-3. The `packages/slack` package shall use the pino-based Logger Factory.
-4. The `packages/remark-attachment-refs` package shall use the pino-based Logger Factory.
-5. The Logger Factory shall be published as a shared package within the monorepo so that all consumers import from a single source.
-
-### Requirement 9: Dependency Cleanup
-
-**Objective:** As a maintainer, I want all bunyan-related and morgan dependencies removed after migration, so that the dependency tree is clean and there is no dead code.
-
-#### Acceptance Criteria
-1. When migration is complete, the monorepo shall have no references to `bunyan`, `universal-bunyan`, `bunyan-format`, `express-bunyan-logger`, `browser-bunyan`, `@browser-bunyan/console-formatted-stream`, or `@types/bunyan` in any `package.json`.
-2. When migration is complete, the monorepo shall have no references to `morgan` or `@types/morgan` in any `package.json`.
-3. When migration is complete, no source file shall contain imports or requires of the removed packages.
-
-### Requirement 11: Preserve Pino's Performance Characteristics
-
-**Objective:** As a developer, I want the logger implementation to honour pino's design philosophy of minimal overhead in the main thread, so that migrating from bunyan does not introduce performance regressions.
-
-#### Acceptance Criteria
-1. The Logger Factory shall create pino's worker-thread transport (`pino.transport()`) **at most once** per application lifetime (i.e., during `initializeLoggerFactory`), regardless of the number of unique namespaces.
-2. The Logger Factory shall create per-namespace loggers by calling `.child()` on a shared root pino instance, not by calling `pino()` and `pino.transport()` independently for each namespace.
-3. The Logger Factory shall not perform any blocking I/O or expensive computation on the hot path of each log method call (level-checking is performed by pino's internal mechanism and is acceptable).
-4. The number of active Worker threads used by the logger subsystem shall remain constant after the first call to `loggerFactory()`, regardless of how many distinct namespaces are subsequently requested.
-
-### Requirement 10: Backward-Compatible Log API
-
-**Objective:** As a developer, I want the new logger to expose the same method signatures as the current bunyan logger, so that existing log call sites require minimal or no changes.
-
-#### Acceptance Criteria
-1. The pino logger shall support `.info()`, `.debug()`, `.warn()`, `.error()`, `.trace()`, and `.fatal()` methods with the same argument patterns as bunyan (message string, optional object, optional error).
-2. If bunyan-specific APIs (e.g., `logger.child()`, serializers) are used at any call sites, the pino equivalent shall be provided or the call site shall be adapted.
-3. The Logger Factory shall export a TypeScript type for the logger instance that is compatible with the pino Logger type.
-
-### Requirement 12: Bunyan-Like Output Format (Development Only)
-
-**Objective:** As a developer, I want the log output in development mode to resemble bunyan-format's "short" mode, so that the visual experience remains familiar after migration.
-
-#### Acceptance Criteria
-1. While in development mode (`NODE_ENV !== 'production'`), the Logger Factory shall output each log line in the format: `HH:mm:ss.SSSZ LEVEL name: message` (e.g., `10:06:30.419Z DEBUG growi:service:page: some message`).
-2. The level label shall be right-aligned to 5 characters (e.g., `DEBUG`, ` INFO`, ` WARN`).
-3. The timestamp shall be UTC time-only in ISO 8601 format (`HH:mm:ss.SSSZ`), without date or surrounding brackets.
-4. The logger namespace (`name` field) shall appear directly after the level label, followed by a colon and the message, without parentheses.
-5. Log lines shall be colorized by level (cyan for DEBUG, green for INFO, yellow for WARN, red for ERROR).
-6. The bunyan-like format shall be implemented as a custom pino transport module within `@growi/logger`, so that `pino.transport()` can load it in a worker thread without function serialization issues.
-7. The bunyan-format transport module shall only be imported in development mode. In production, the module shall not be imported or bundled.
-8. While in production mode with `FORMAT_NODE_LOG` enabled, the Logger Factory shall use standard pino-pretty (not the bunyan-format transport) for formatted output.
-
-### Requirement 13: HTTP Logger Middleware Encapsulation
-
-**Objective:** As a developer, I want the HTTP request logging middleware encapsulated within `@growi/logger`, so that consumer applications do not need to depend on or import `pino-http` directly.
-
-#### Acceptance Criteria
-1. The `@growi/logger` package shall export a `createHttpLoggerMiddleware(options)` function that returns Express-compatible middleware for HTTP request logging.
-2. The middleware factory shall accept options for the logger namespace (defaulting to `'express'`) and optional `autoLogging` configuration (e.g., route ignore patterns).
-3. While in development mode, the middleware shall apply morgan-like formatting (custom success/error messages, custom log levels) via dynamic import. In production mode, the morgan-like format module shall not be imported; pino-http's default message format shall be used.
-4. After the encapsulation, `apps/app` and `apps/slackbot-proxy` shall not import `pino-http` directly; all HTTP logging shall go through `@growi/logger`.
-5. The `pino-http` dependency shall move from consumer applications to `@growi/logger`'s `dependencies`.
-6. The `morganLikeFormatOptions` module shall only be imported in development mode (dynamic import). In production, the module shall not be imported or bundled.
-7. The `pino-http` module shall be imported lazily inside the `createHttpLoggerMiddleware` function body (not at module top-level), so that bundlers (e.g., Turbopack, webpack) do not include the Node.js-only `pino-http` in browser bundles when `@growi/logger` is imported by shared/universal code.
-8. While in development mode with morgan-like formatting enabled, the HTTP log output shall suppress the verbose `req` and `res` serialized objects; the `customSuccessMessage` output (method, URL, status code, response time) is sufficient for development readability.
-9. While in development mode, the morgan-like format shall colorize the HTTP status code by range (2xx=green, 3xx=cyan, 4xx=yellow, 5xx=red) and dim the response time, respecting the `NO_COLOR` environment variable.

+ 0 - 263
.kiro/specs/migrate-logger-to-pino/tasks.md

@@ -1,263 +0,0 @@
-# Implementation Plan
-
-- [x] 1. Scaffold the @growi/logger shared package
-- [x] 1.1 Initialize the package directory, package.json, and TypeScript configuration within the monorepo packages directory
-  - Create the workspace entry as `@growi/logger` with pino v9.x and minimatch as dependencies, pino-pretty as an optional peer dependency
-  - Configure TypeScript with strict mode, ESM output, and appropriate path aliases
-  - Set up the package entry points (main, types, browser) so that bundlers resolve the correct build for Node.js vs browser
-  - Add vitest configuration for unit testing within the package
-  - _Requirements: 8.5_
-
-- [x] 1.2 Define the shared type contracts and configuration interface
-  - Define the `LoggerConfig` type representing a namespace-pattern-to-level mapping (including a `default` key)
-  - Define the `LoggerFactoryOptions` type accepted by the initialization function
-  - Export the pino `Logger` type so consumers can type-annotate their logger variables without importing pino directly
-  - _Requirements: 10.3_
-
-- [x] 2. Implement environment variable parsing and level resolution
-- [x] 2.1 (P) Build the environment variable parser
-  - Read the six log-level environment variables (`DEBUG`, `TRACE`, `INFO`, `WARN`, `ERROR`, `FATAL`) from the process environment
-  - Split each variable's value by commas and trim whitespace to extract individual namespace patterns
-  - Return a flat config map where each namespace pattern maps to its corresponding level string
-  - Handle edge cases: empty values, missing variables, duplicate patterns (last wins)
-  - Write unit tests covering: single variable with multiple patterns, all six variables set, no variables set, whitespace handling
-  - _Requirements: 3.1, 3.4, 3.5_
-
-- [x] 2.2 (P) Build the level resolver with glob pattern matching
-  - Accept a namespace string, a config map, and an env-override map; return the resolved level
-  - Check env-override map first (using minimatch for glob matching), then config map, then fall back to the config `default` entry
-  - When multiple patterns match, prefer the most specific (longest non-wildcard prefix) match
-  - Write unit tests covering: exact match, glob wildcard match, env override precedence over config, fallback to default, no matching pattern
-  - _Requirements: 2.1, 2.3, 2.4, 3.2, 3.3_
-
-- [x] 3. Implement the transport factory for dev, prod, and browser environments
-- [x] 3.1 (P) Build the Node.js transport configuration
-  - In development mode, produce pino-pretty transport options with human-readable timestamps, hidden pid/hostname fields, and multi-line output
-  - In production mode, produce raw JSON output to stdout by default
-  - When the `FORMAT_NODE_LOG` environment variable is unset or truthy in production, produce pino-pretty transport options with long-format output instead of raw JSON
-  - Include the logger namespace (`name` field) in all output configurations
-  - Write unit tests verifying correct options for each combination of NODE_ENV and FORMAT_NODE_LOG
-  - _Requirements: 5.1, 5.2, 5.3, 5.4_
-
-- [x] 3.2 (P) Build the browser transport configuration
-  - Detect the browser environment using window/document checks
-  - In browser development mode, produce pino browser options that output to the developer console with the resolved namespace level
-  - In browser production mode, produce pino browser options that default to `error` level to suppress non-critical console output
-  - Write unit tests verifying browser options for dev and prod scenarios
-  - _Requirements: 4.1, 4.2, 4.3, 4.4_
-
-- [x] 4. Implement the logger factory with caching and platform detection
-- [x] 4.1 Build the initialization and factory functions
-  - Implement `initializeLoggerFactory(options)` that stores the merged configuration, pre-parses environment overrides, and prepares the transport config
-  - Implement `loggerFactory(name)` that checks the cache for an existing logger, resolves the level via the level resolver, creates a pino instance with appropriate transport options, caches it, and returns it
-  - Detect the runtime platform (Node.js vs browser) and apply the corresponding transport configuration from the transport factory
-  - Ensure the module exports `loggerFactory` as the default export and `initializeLoggerFactory` as a named export for backward compatibility with existing import patterns
-  - Write unit tests covering: cache hit returns same instance, different namespaces return different instances, initialization stores config correctly
-  - _Requirements: 1.1, 1.2, 1.3, 1.4, 4.1, 10.1_
-
-- [x] 5. Migrate shared packages to @growi/logger (small scope first)
-- [x] 5.1 (P) Update packages/slack logger to use @growi/logger
-  - Replace the logger factory implementation to import from `@growi/logger` instead of universal-bunyan
-  - Update the inline config (`{ default: 'info' }`) to use the @growi/logger initialization pattern
-  - Replace bunyan type imports with the @growi/logger Logger type
-  - Add `@growi/logger` to packages/slack dependencies
-  - Run TypeScript compilation to verify no type errors
-  - _Requirements: 8.3_
-
-- [x] 5.2 (P) Update packages/remark-attachment-refs logger to use @growi/logger
-  - Replace the logger factory implementation to import from `@growi/logger`
-  - Update configuration and type imports to match the new package
-  - Add `@growi/logger` to packages/remark-attachment-refs dependencies
-  - Run TypeScript compilation to verify no type errors
-  - _Requirements: 8.4_
-
-- [x] 5.3 Fix pino-style logger call sites in packages/slack
-  - In the following files, convert all `logger.method('message', obj)` calls to the pino-canonical form `logger.method({ obj }, 'message')` (object first, message second)
-  - `src/middlewares/verify-growi-to-slack-request.ts` (lines 25, 34)
-  - `src/middlewares/verify-slack-request.ts` (lines 25, 36, 45, 76)
-  - `src/utils/interaction-payload-accessor.ts` (line 104)
-  - Run `pnpm --filter @growi/slack lint:typecheck` and confirm zero TS2769 errors
-  - _Requirements: 10.1_
-
-- [x] 5.4 Fix pino-style logger call site in packages/remark-attachment-refs
-  - In `src/client/services/renderer/refs.ts` (line 107), convert `logger.debug('message', attributes)` to `logger.debug({ attributes }, 'message')`
-  - Run `pnpm --filter @growi/remark-attachment-refs lint:typecheck` and confirm the TS2769 error is gone
-  - _Requirements: 10.1_
-
-- [x] 5.5 Migrate packages/remark-lsx server routes to use @growi/logger
-  - Add `@growi/logger` to packages/remark-lsx dependencies
-  - Create `src/utils/logger/index.ts` following the same pattern as remark-attachment-refs (import from `@growi/logger`, call `initializeLoggerFactory`, re-export `loggerFactory`)
-  - Replace `console.error` calls in `src/server/routes/list-pages/index.ts` (lines 89, 145-148) with proper logger calls using `loggerFactory('growi:remark-lsx:routes:list-pages')`
-  - Remove the `biome-ignore lint/suspicious/noConsole` comments from the replaced call sites
-  - Run `pnpm --filter @growi/remark-lsx lint:typecheck` to confirm no type errors
-  - _Requirements: 8.5_
-
-- [x] 6. Migrate apps/slackbot-proxy to @growi/logger
-- [x] 6.1 Replace the logger factory and HTTP middleware in slackbot-proxy
-  - Update the slackbot-proxy logger utility to import from `@growi/logger` and call `initializeLoggerFactory` with its existing dev/prod config
-  - Replace express-bunyan-logger and morgan usage in the server setup with pino-http middleware
-  - Replace all `import type Logger from 'bunyan'` references with the @growi/logger Logger type
-  - Add `@growi/logger` and `pino-http` to slackbot-proxy dependencies
-  - Run TypeScript compilation to verify no type errors
-  - _Requirements: 8.2, 6.1_
-
-- [x] 6.6 Fix pino-style logger call sites in apps/slackbot-proxy
-  - In the following files, convert all `logger.method('message', obj)` calls to `logger.method({ obj }, 'message')`
-  - `src/controllers/growi-to-slack.ts` (lines 109, 179, 231, 243, 359)
-  - `src/controllers/slack.ts` (lines 388, 586)
-  - `src/services/RegisterService.ts` (line 165)
-  - Run `pnpm --filter @growi/slackbot-proxy lint:typecheck` and confirm zero TS2769 errors
-  - _Requirements: 10.1_
-
-- [x] 6.7 Fix @growi/logger Logger type export and remove `as any` cast in slackbot-proxy
-  - In `packages/logger`, update the `loggerFactory` return type so it is compatible with `pino-http`'s `logger` option (i.e., `pino.Logger` without `<never>` narrowing, or by exporting `Logger<string>`)
-  - After the type export is fixed, remove the `as any` cast from `apps/slackbot-proxy/src/Server.ts` (line 166) and the associated `biome-ignore` comment
-  - Run `pnpm --filter @growi/slackbot-proxy lint:typecheck` to confirm no residual type errors
-  - _Requirements: 10.3_
-
-- [x] 6.5 Fix logger factory to preserve pino's single-worker-thread performance model
-  - Refactor `initializeLoggerFactory` to create the pino transport (`pino.transport()`) and root pino logger **once**, storing them in module scope
-  - Set the root logger's level to `'trace'` so that individual child loggers can apply their own resolved level without being silenced by the root
-  - Refactor `loggerFactory(name)` to call `rootLogger.child({ name })` and then set `childLogger.level = resolvedLevel` instead of calling `pino()` + `pino.transport()` per namespace
-  - Handle browser mode separately: the root browser logger is created once in `initializeLoggerFactory`; `loggerFactory` still calls `.child({ name })` and applies the resolved level
-  - Update unit tests in `logger-factory.spec.ts` to verify that calling `loggerFactory` for N distinct namespaces does not create N independent pino instances (all children share the root transport)
-  - _Requirements: 11.1, 11.2, 11.3, 11.4_
-
-- [x] 7. Migrate apps/app to @growi/logger (largest scope)
-- [x] 7.1 Replace the logger factory module in apps/app
-  - Update the apps/app logger utility to import from `@growi/logger` instead of `universal-bunyan`
-  - Call `initializeLoggerFactory` at application startup with the existing dev/prod config files (preserve current config content)
-  - Re-export `loggerFactory` as the default export so all existing consumer imports continue to work unchanged
-  - Add `@growi/logger` to apps/app dependencies and ensure pino-pretty is available for development formatting
-  - _Requirements: 8.1, 2.2_
-
-- [x] 7.2 Replace HTTP request logging middleware in apps/app
-  - Remove the morgan middleware (development mode) and express-bunyan-logger middleware (production mode) from the Express initialization
-  - Add pino-http middleware configured with a logger from the factory using the `express` namespace
-  - Configure route skipping to exclude `/_next/static/` paths in non-production mode
-  - Verify the middleware produces log entries containing method, URL, status code, and response time
-  - _Requirements: 6.1, 6.2, 6.3, 6.4_
-
-- [x] 7.3 Update the OpenTelemetry diagnostic logger adapter
-  - Rename the adapter class from `DiagLoggerBunyanAdapter` to `DiagLoggerPinoAdapter` and update the import to use pino types
-  - Preserve the existing `parseMessage` helper logic that parses JSON strings and merges argument objects
-  - Confirm the verbose-to-trace level mapping continues to work with pino's trace level
-  - Update the OpenTelemetry SDK configuration to disable `@opentelemetry/instrumentation-pino` instead of `@opentelemetry/instrumentation-bunyan`
-  - _Requirements: 7.1, 7.2, 7.3_
-
-- [x] 7.4 Update all bunyan type references in apps/app source files
-  - Replace `import type Logger from 'bunyan'` with the Logger type exported from `@growi/logger` across all source files in apps/app
-  - Verify that pino's Logger type is compatible with all existing usage patterns (info, debug, warn, error, trace, fatal method calls)
-  - Run the TypeScript compiler to confirm no type errors
-  - _Requirements: 10.1, 10.2, 10.3_
-
-- [x] 8. Remove old logging dependencies and verify cleanup
-- [x] 8.1 Remove bunyan-related packages from all package.json files
-  - Remove `bunyan`, `universal-bunyan`, `bunyan-format`, `express-bunyan-logger`, `browser-bunyan`, `@browser-bunyan/console-formatted-stream`, `@types/bunyan` from every package.json in the monorepo
-  - Remove `morgan` and `@types/morgan` from every package.json in the monorepo
-  - Run `pnpm install` to update the lockfile and verify no broken peer dependency warnings
-  - _Requirements: 9.1, 9.2_
-
-- [x] 8.2 Verify no residual references to removed packages
-  - Search all source files for any remaining imports or requires of the removed packages (bunyan, universal-bunyan, browser-bunyan, express-bunyan-logger, morgan, bunyan-format)
-  - Search all configuration and type definition files for stale bunyan references
-  - Fix any remaining references found during the search
-  - _Requirements: 9.3_
-
-- [x] 9. Run full monorepo validation
-- [x] 9.1 Execute lint, type-check, test, and build across the monorepo
-  - Run `turbo run lint --filter @growi/app` and fix any lint errors related to the migration
-  - Run `turbo run test --filter @growi/app` and verify all existing tests pass
-  - Run `turbo run build --filter @growi/app` and confirm the production build succeeds
-  - Run the same checks for slackbot-proxy and any other affected packages
-  - Verify the @growi/logger package's own tests pass
-  - _Requirements: 1.4, 8.1, 8.2, 8.3, 8.4, 10.1, 10.2_
-
-- [x] 10. Improve log output formatting for readability
-- [x] 10.1 (P) Differentiate pino-pretty singleLine between dev and production FORMAT_NODE_LOG
-  - In the transport factory, change the production + FORMAT_NODE_LOG path to use `singleLine: true` for concise one-liner output
-  - Keep the development path at `singleLine: false` so developers see full multi-line context
-  - Update unit tests to verify: dev returns `singleLine: false`, production + FORMAT_NODE_LOG returns `singleLine: true`, production without FORMAT_NODE_LOG still returns no transport
-  - _Requirements: 5.1, 5.3_
-
-- [x] 10.2 (P) Add morgan-like HTTP request message formatting to pino-http in apps/app
-  - Configure `customSuccessMessage` to produce `METHOD /url STATUS - TIMEms` format (e.g., `GET /page/path 200 - 12ms`)
-  - Configure `customErrorMessage` to include the error message alongside method, URL, and status code
-  - Configure `customLogLevel` to return `warn` for 4xx responses and `error` for 5xx or error responses, keeping `info` for successful requests
-  - Verify that `/_next/static/` path skipping in dev mode still works after the changes
-  - _Requirements: 6.1, 6.4_
-
-- [x] 10.3 (P) Add morgan-like HTTP request message formatting to pino-http in apps/slackbot-proxy
-  - Apply the same `customSuccessMessage`, `customErrorMessage`, and `customLogLevel` configuration as apps/app
-  - _Requirements: 6.1, 6.4_
-
-- [x] 11. Validate formatting improvements
-- [x] 11.1 Run tests and build for affected packages
-  - Run the @growi/logger package tests to confirm transport factory changes pass
-  - Run lint and type-check for apps/app and apps/slackbot-proxy
-  - Verify the production build succeeds
-  - _Requirements: 5.1, 5.3, 6.1, 6.4_
-
-- [x] 12. Implement bunyan-like output format (development only)
-- [x] 12.1 Create the bunyan-format custom transport module
-  - Create `packages/logger/src/transports/bunyan-format.ts` that default-exports a function returning a pino-pretty stream
-  - Use `customPrettifiers.time` to format epoch as `HH:mm:ss.SSSZ` (UTC time-only, no brackets)
-  - Use `customPrettifiers.level` to return `${label.padStart(5)} ${log.name}` (right-aligned 5-char level + namespace)
-  - Set `ignore: 'pid,hostname,name'` so name appears via the level prettifier, not in pino-pretty's default parens
-  - Accept `singleLine` option to pass through to pino-pretty
-  - Verify the module is built to `dist/transports/bunyan-format.js` by vite's `preserveModules` config
-  - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6_
-
-- [x] 12.2 Update TransportFactory to use bunyan-format transport in dev only
-  - In the **development** branch of `createNodeTransportOptions`, change the transport target from `'pino-pretty'` to the resolved path of `bunyan-format.js` (via `import.meta.url`)
-  - Remove `translateTime` and `ignore` options from the dev transport config (now handled inside the custom transport)
-  - Pass `singleLine: false` for dev
-  - In the **production + FORMAT_NODE_LOG** branch, keep `target: 'pino-pretty'` with standard options (`translateTime: 'SYS:standard'`, `ignore: 'pid,hostname'`, `singleLine: true`) — do NOT use bunyan-format
-  - The bunyan-format module path is only resolved in the dev code path, ensuring it is never imported in production
-  - Update unit tests in `transport-factory.spec.ts`: dev target contains `bunyan-format`; prod + FORMAT_NODE_LOG target is `'pino-pretty'`
-  - _Requirements: 12.1, 12.6, 12.7, 12.8_
-
-- [x] 12.3 Verify bunyan-format output
-  - Run the dev server and confirm log output matches the bunyan-format "short" style: `HH:mm:ss.SSSZ LEVEL name: message`
-  - Confirm colorization works (DEBUG=cyan, INFO=green, WARN=yellow, ERROR=red)
-  - Confirm multi-line output in dev (extra fields on subsequent lines)
-  - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5_
-
-- [x] 13. Encapsulate pino-http in @growi/logger
-- [x] 13.1 Create HTTP logger middleware factory in @growi/logger
-  - Create `packages/logger/src/http-logger.ts` exporting `async createHttpLoggerMiddleware(options?)`
-  - The function creates `pinoHttp` middleware internally with `loggerFactory(namespace)`
-  - In development mode (`NODE_ENV !== 'production'`): dynamically import `morganLikeFormatOptions` via `await import('./morgan-like-format-options')` and apply to pino-http options
-  - In production mode: use pino-http with default message formatting (no morgan-like module imported)
-  - Accept optional `namespace` (default: `'express'`) and `autoLogging` options
-  - Handle the `Logger<string>` → pino-http's expected Logger type assertion internally
-  - Add `pino-http` to `@growi/logger` package.json dependencies
-  - Export `createHttpLoggerMiddleware` from `packages/logger/src/index.ts`
-  - _Requirements: 13.1, 13.2, 13.3, 13.5, 13.6_
-
-- [x] 13.2 (P) Migrate apps/app to use createHttpLoggerMiddleware
-  - Replace the direct `pinoHttp` import and configuration in `apps/app/src/server/crowi/index.ts` with `await createHttpLoggerMiddleware(...)` from `@growi/logger`
-  - Pass the `/_next/static/` autoLogging ignore function via the options
-  - Remove `pino-http` and its type imports from the file
-  - Remove `morganLikeFormatOptions` import (now applied internally in dev only)
-  - Remove `pino-http` from `apps/app/package.json` if no longer directly used
-  - Run `pnpm --filter @growi/app lint:typecheck` to confirm no type errors
-  - _Requirements: 13.4_
-
-- [x] 13.3 (P) Migrate apps/slackbot-proxy to use createHttpLoggerMiddleware
-  - Replace the direct `pinoHttp` import and configuration in `apps/slackbot-proxy/src/Server.ts` with `await createHttpLoggerMiddleware(...)` from `@growi/logger`
-  - Remove `pino-http` and its type imports from the file
-  - Remove `morganLikeFormatOptions` import (now applied internally in dev only)
-  - Remove the `as unknown as` type assertion (now handled internally)
-  - Remove `pino-http` from `apps/slackbot-proxy/package.json` if no longer directly used
-  - Run `pnpm --filter @growi/slackbot-proxy lint:typecheck` to confirm no type errors
-  - _Requirements: 13.4_
-
-- [x] 14. Validate bunyan-format and HTTP encapsulation
-- [x] 14.1 Run full validation
-  - Run `@growi/logger` package tests
-  - Run lint and type-check for apps/app and apps/slackbot-proxy
-  - Run `turbo run build --filter @growi/app` to verify production build succeeds
-  - Verify no remaining direct `pino-http` imports in apps/app or apps/slackbot-proxy source files
-  - Verify that bunyan-format transport and morganLikeFormatOptions are NOT imported in production (grep for dynamic import pattern)
-  - _Requirements: 12.1, 12.6, 12.7, 13.4, 13.5, 13.6_

+ 128 - 0
apps/app/playwright/23-editor/emacs-keymap.spec.ts

@@ -0,0 +1,128 @@
+import { expect, test } from '@playwright/test';
+
+/**
+ * Tests for Emacs keymap functionality in the editor.
+ * Verifies that the registered EmacsHandler bindings produce the expected
+ * markdown output in the editor source — i.e. the observable contract
+ * (content changes) rather than internal implementation details.
+ *
+ * Keymap isolation strategy: page.route intercepts GET /_api/v3/personal-setting/editor-settings
+ * and returns keymapMode:'emacs' without touching the database.  PUT requests are swallowed for
+ * the same reason.  Because the route is scoped to the test's page instance, no other test file
+ * is affected and no afterEach cleanup is required.
+ *
+ * @see packages/editor/src/client/services-internal/keymaps/emacs/
+ * Requirements: 4.1, 5.2, 9.3
+ */
+
+const EDITOR_SETTINGS_ROUTE = '**/_api/v3/personal-setting/editor-settings';
+
+test.describe
+  .serial('Emacs keymap mode', () => {
+    test.beforeEach(async ({ page }) => {
+      // Return keymapMode:'emacs' for every settings fetch without writing to DB.
+      // PUT requests (e.g. from UI interactions) are also swallowed so the DB stays clean.
+      await page.route(EDITOR_SETTINGS_ROUTE, async (route) => {
+        if (route.request().method() === 'GET') {
+          await route.fulfill({
+            contentType: 'application/json',
+            body: JSON.stringify({ keymapMode: 'emacs' }),
+          });
+        } else {
+          await route.fulfill({
+            status: 200,
+            contentType: 'application/json',
+            body: '{}',
+          });
+        }
+      });
+
+      await page.goto('/Sandbox/emacs-keymap-test-page');
+
+      // Open Editor
+      await expect(page.getByTestId('editor-button')).toBeVisible();
+      await page.getByTestId('editor-button').click();
+      await expect(page.locator('.cm-content')).toBeVisible();
+      await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+    });
+
+    test('C-c C-s b should wrap text in bold markdown markers (Req 4.1)', async ({
+      page,
+    }) => {
+      // Focus the editor
+      await page.locator('.cm-content').click();
+
+      // With no selection, C-c C-s b inserts ** markers and positions cursor between them
+      await page.keyboard.press('Control+c');
+      await page.keyboard.press('Control+s');
+      await page.keyboard.press('b');
+
+      // Type text inside the inserted markers
+      await page.keyboard.type('bold text');
+
+      // Verify: bold markdown markers surround the typed text in the editor source
+      await expect(page.locator('.cm-content')).toContainText('**bold text**');
+    });
+
+    test('C-c C-l should insert a markdown link template (Req 5.2)', async ({
+      page,
+    }) => {
+      // Focus the editor
+      await page.locator('.cm-content').click();
+
+      // With no selection, C-c C-l inserts []() and positions cursor after [
+      await page.keyboard.press('Control+c');
+      await page.keyboard.press('Control+l');
+
+      // Type the link display text inside the brackets
+      await page.keyboard.type('link text');
+
+      // Verify: link template with typed display text appears in the editor source
+      await expect(page.locator('.cm-content')).toContainText('[link text]()');
+    });
+
+    test('C-c C-n should navigate cursor to the next heading (Req 9.3)', async ({
+      page,
+    }) => {
+      // Set up document with two headings.
+      // Fill directly and wait for the rendered heading text (without # markers) to appear in the
+      // preview, because appendTextToEditorUntilContains checks raw text which markdown headings
+      // strip on render.
+      await page
+        .locator('.cm-content')
+        .fill('# First Heading\n\n## Second Heading');
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        'Second Heading',
+      );
+
+      // Click on the first line to position cursor before "## Second Heading"
+      await page.locator('.cm-line').first().click();
+
+      // Navigate to the next heading with C-c C-n
+      await page.keyboard.press('Control+c');
+      await page.keyboard.press('Control+n');
+
+      // Cursor is now at the beginning of "## Second Heading".
+      // Move to end of that line and append a unique marker to verify cursor position.
+      await page.keyboard.press('End');
+      await page.keyboard.type(' NAVIGATED');
+
+      // Verify: the marker was appended at the second heading, not the first
+      await expect(page.locator('.cm-content')).toContainText(
+        '## Second Heading NAVIGATED',
+      );
+    });
+
+    test('C-x C-s should save the page (Req 6.1)', async ({ page }) => {
+      // Type content to ensure there is something to save
+      await page.locator('.cm-content').click();
+      await page.keyboard.type('Emacs save test');
+
+      // Save with the Emacs two-stroke save binding
+      await page.keyboard.press('Control+x');
+      await page.keyboard.press('Control+s');
+
+      // Expect a success toast notification confirming the page was saved
+      await expect(page.locator('.Toastify__toast--success')).toBeVisible();
+    });
+  });

+ 7 - 26
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -14,6 +14,7 @@ import adminRequiredFactory from '~/server/middlewares/admin-required';
 import loginRequiredFactory from '~/server/middlewares/login-required';
 import { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
+import { createSMTPClient } from '~/server/service/mail/smtp';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
@@ -724,33 +725,13 @@ module.exports = (crowi: Crowi) => {
       throw Error('fromAddress is not setup');
     }
 
-    const smtpHost = configManager.getConfig('mail:smtpHost');
-    const smtpPort = configManager.getConfig('mail:smtpPort');
-    const smtpUser = configManager.getConfig('mail:smtpUser');
-    const smtpPassword = configManager.getConfig('mail:smtpPassword');
-
-    // Define the option object with possible 'auth' and 'secure' properties
-    const option: {
-      host: string | undefined;
-      port: string | undefined;
-      auth?: { user: string; pass: string };
-      secure?: boolean;
-    } = {
-      host: smtpHost,
-      port: smtpPort,
-    };
-    if (smtpUser && smtpPassword) {
-      option.auth = {
-        user: smtpUser,
-        pass: smtpPassword,
-      };
-    }
-    if (option.port === '465') {
-      option.secure = true;
+    const smtpClient = createSMTPClient(configManager);
+    if (smtpClient == null) {
+      throw Error(
+        'SMTP client could not be created. Please check SMTP settings.',
+      );
     }
-
-    const smtpClient = mailService.createSMTPClient(option);
-    logger.debug('mailer setup for validate SMTP setting', smtpClient);
+    logger.debug({ smtpClient }, 'mailer setup for validate SMTP setting');
 
     const mailOptions = {
       from: fromAddress,

+ 12 - 4
apps/app/src/server/routes/apiv3/forgot-password.js

@@ -14,6 +14,7 @@ import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import httpErrorHandler from '../../middlewares/http-error-handler';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword');
@@ -85,6 +86,7 @@ module.exports = (crowi) => {
 
   const checkPassportStrategyMiddleware =
     checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
+  const ALLOWED_TEMPLATE_NAMES = ['passwordReset', 'passwordResetSuccessful'];
 
   async function sendPasswordResetEmail(
     templateFileName,
@@ -93,13 +95,19 @@ module.exports = (crowi) => {
     url,
     expiredAt,
   ) {
+    if (!ALLOWED_TEMPLATE_NAMES.includes(templateFileName)) {
+      throw new Error(`Invalid template name: ${templateFileName}`);
+    }
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      `notifications/${templateFileName}.ejs`,
+    );
+
     return mailService.send({
       to: email,
       subject: '[GROWI] Password Reset',
-      template: join(
-        crowi.localeDir,
-        `${locale}/notifications/${templateFileName}.ejs`,
-      ),
+      template: templatePath,
       vars: {
         appTitle: appService.getAppTitle(),
         email,

+ 12 - 6
apps/app/src/server/routes/apiv3/user-activation.ts

@@ -15,6 +15,8 @@ import { growiInfoService } from '~/server/service/growi-info';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
 
+import { resolveLocalePath } from '../../util/safe-path-utils';
+
 const logger = loggerFactory('growi:routes:apiv3:user-activation');
 
 const PASSOWRD_MINIMUM_NUMBER = 8;
@@ -233,9 +235,10 @@ export const completeRegistrationAction = (crowi: Crowi) => {
               const admins = await User.findAdmins();
               const appTitle = appService.getAppTitle();
               const locale = configManager.getConfig('app:globalLang');
-              const template = path.join(
+              const template = resolveLocalePath(
+                locale,
                 crowi.localeDir,
-                `${locale}/admin/userWaitingActivation.ejs`,
+                'admin/userWaitingActivation.ejs',
               );
               const url = growiInfoService.getSiteUrl();
 
@@ -314,6 +317,12 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
   }
 
   const locale = configManager.getConfig('app:globalLang');
+  const templatePath = resolveLocalePath(
+    locale,
+    localeDir,
+    'notifications/userActivation.ejs',
+  );
+
   const appUrl = growiInfoService.getSiteUrl();
 
   const userRegistrationOrder =
@@ -330,10 +339,7 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
   return mailService.send({
     to: email,
     subject: '[GROWI] User Activation',
-    template: path.join(
-      localeDir,
-      `${locale}/notifications/userActivation.ejs`,
-    ),
+    template: templatePath,
     vars: {
       appTitle: appService.getAppTitle(),
       email,

+ 14 - 9
apps/app/src/server/routes/apiv3/users.js

@@ -4,7 +4,6 @@ import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import express from 'express';
 import { body, query } from 'express-validator';
-import path from 'pathe';
 import { isEmail } from 'validator';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
@@ -25,6 +24,7 @@ import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 
 const logger = loggerFactory('growi:routes:apiv3:users');
 
@@ -206,6 +206,12 @@ module.exports = (crowi) => {
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
     const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      'admin/userInvitation.ejs',
+    );
+
     const failedToSendEmailList = [];
 
     for (const user of userList) {
@@ -214,10 +220,7 @@ module.exports = (crowi) => {
         await mailService.send({
           to: user.email,
           subject: `Invitation to ${appTitle}`,
-          template: path.join(
-            crowi.localeDir,
-            `${locale}/admin/userInvitation.ejs`,
-          ),
+          template: templatePath,
           vars: {
             email: user.email,
             password: user.password,
@@ -242,14 +245,16 @@ module.exports = (crowi) => {
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
     const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      'admin/userResetPassword.ejs',
+    );
 
     await mailService.send({
       to: user.email,
       subject: `New password for ${appTitle}`,
-      template: path.join(
-        crowi.localeDir,
-        `${locale}/admin/userResetPassword.ejs`,
-      ),
+      template: templatePath,
       vars: {
         email: user.email,
         password: user.password,

+ 7 - 5
apps/app/src/server/routes/login.js

@@ -4,6 +4,7 @@ import loggerFactory from '~/utils/logger';
 
 import { UserStatus } from '../models/user/conts';
 import { growiInfoService } from '../service/growi-info';
+import { resolveLocalePath } from '../util/safe-path-utils';
 
 // disable all of linting
 // because this file is a deprecated legacy of Crowi
@@ -11,7 +12,6 @@ import { growiInfoService } from '../service/growi-info';
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi, app) => {
   const logger = loggerFactory('growi:routes:login');
-  const path = require('path');
   const { User } = crowi.models;
   const { appService, aclService, mailService, activityService } = crowi;
   const activityEvent = crowi.events.activity;
@@ -23,15 +23,17 @@ module.exports = (crowi, app) => {
     const admins = await User.findAdmins();
     const appTitle = appService.getAppTitle();
     const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      'admin/userWaitingActivation.ejs',
+    );
 
     const promises = admins.map((admin) => {
       return mailService.send({
         to: admin.email,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-        template: path.join(
-          crowi.localeDir,
-          `${locale}/admin/userWaitingActivation.ejs`,
-        ),
+        template: templatePath,
         vars: {
           adminUser: admin,
           createdUser: userData,

+ 16 - 7
apps/app/src/server/service/global-notification/global-notification-mail.ts

@@ -1,4 +1,3 @@
-import nodePath from 'node:path';
 import type { IUser } from '@growi/core/dist/interfaces';
 
 import type Crowi from '~/server/crowi';
@@ -12,6 +11,7 @@ import { configManager } from '~/server/service/config-manager';
 import { growiInfoService } from '~/server/service/growi-info';
 import loggerFactory from '~/utils/logger';
 
+import { resolveLocalePath } from '../../util/safe-path-utils';
 import type { GlobalNotificationEventVars } from './types';
 
 const _logger = loggerFactory('growi:service:GlobalNotificationMailService');
@@ -89,17 +89,26 @@ class GlobalNotificationMailService {
     triggeredBy: IUser,
     { comment, oldPath }: GlobalNotificationEventVars,
   ): MailOption {
-    const locale = configManager.getConfig('app:globalLang');
-    // validate for all events
     if (event == null || page == null || triggeredBy == null) {
       throw new Error(
-        `invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`,
+        `Invalid vars supplied to GlobalNotificationMailService.generateOption: event=${event}`,
       );
     }
+    const validEvents = Object.values(
+      GlobalNotificationSettingEvent,
+    ) as string[];
+    if (!validEvents.includes(event)) {
+      _logger.error(`Unknown global notification event: ${event}`);
+      throw new Error(`Unknown global notification event: ${event}`);
+    }
 
-    const template = nodePath.join(
+    const castedEvent =
+      event as (typeof GlobalNotificationSettingEvent)[keyof typeof GlobalNotificationSettingEvent];
+    const locale = configManager.getConfig('app:globalLang');
+    const template = resolveLocalePath(
+      locale,
       this.crowi.localeDir,
-      `${locale}/notifications/${event}.ejs`,
+      `notifications/${castedEvent}.ejs`,
     );
 
     const path = page.path;
@@ -115,7 +124,7 @@ class GlobalNotificationMailService {
       username: triggeredBy.username,
     };
 
-    switch (event) {
+    switch (castedEvent) {
       case GlobalNotificationSettingEvent.PAGE_CREATE:
         subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;

+ 31 - 11
apps/app/src/server/service/installer.ts

@@ -8,6 +8,7 @@ import path from 'path';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../crowi';
+import { SUPPORTED_LOCALES } from '../util/safe-path-utils';
 import { configManager } from './config-manager';
 
 const logger = loggerFactory('growi:service:installer');
@@ -19,6 +20,11 @@ export type AutoInstallOptions = {
   serverDate?: Date;
 };
 
+const getSafeLang = (lang: Lang): Lang => {
+  if (SUPPORTED_LOCALES.includes(lang)) return lang;
+  return 'en_US';
+};
+
 export class InstallerService {
   crowi: Crowi;
 
@@ -44,7 +50,12 @@ export class InstallerService {
     const { pageService } = this.crowi;
 
     try {
-      const markdown = fs.readFileSync(filePath);
+      const normalizedPath = path.resolve(filePath);
+      const baseDir = path.resolve(this.crowi.localeDir);
+      if (!normalizedPath.startsWith(baseDir)) {
+        throw new Error(`Path traversal detected: ${normalizedPath}`);
+      }
+      const markdown = fs.readFileSync(normalizedPath);
       return pageService.forceCreateBySystem(pagePath, markdown.toString(), {});
     } catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
@@ -56,27 +67,33 @@ export class InstallerService {
     initialPagesCreatedAt?: Date,
   ): Promise<any> {
     const { localeDir } = this.crowi;
+
+    const safeLang = getSafeLang(lang);
+
     // create /Sandbox/*
     /*
      * Keep in this order to
      *   1. avoid creating the same pages
      *   2. avoid difference for order in VRT
      */
-    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox');
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-markdown.md'),
+      path.join(localeDir, safeLang, 'sandbox.md'),
+      '/Sandbox',
+    );
+    await this.createPage(
+      path.join(localeDir, safeLang, 'sandbox-markdown.md'),
       '/Sandbox/Markdown',
     );
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-bootstrap5.md'),
+      path.join(localeDir, safeLang, 'sandbox-bootstrap5.md'),
       '/Sandbox/Bootstrap5',
     );
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-diagrams.md'),
+      path.join(localeDir, safeLang, 'sandbox-diagrams.md'),
       '/Sandbox/Diagrams',
     );
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-math.md'),
+      path.join(localeDir, safeLang, 'sandbox-math.md'),
       '/Sandbox/Math',
     );
 
@@ -123,11 +140,13 @@ export class InstallerService {
     globalLang: Lang,
     options?: AutoInstallOptions,
   ): Promise<void> {
+    const safeLang = getSafeLang(globalLang);
+
     await configManager.updateConfigs(
       {
         'app:installed': true,
         'app:isV5Compatible': true,
-        'app:globalLang': globalLang,
+        'app:globalLang': safeLang,
       },
       { skipPubsub: true },
     );
@@ -149,14 +168,15 @@ export class InstallerService {
     globalLang: Lang,
     options?: AutoInstallOptions,
   ): Promise<IUser> {
-    await this.initDB(globalLang, options);
+    const safeLang = getSafeLang(globalLang);
 
+    await this.initDB(safeLang, options);
     const User = mongoose.model<IUser, { createUser }>('User');
 
     // create portal page for '/' before creating admin user
     try {
       await this.createPage(
-        path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
+        path.join(this.crowi.localeDir, safeLang, 'welcome.md'),
         '/',
       );
     } catch (err) {
@@ -172,12 +192,12 @@ export class InstallerService {
         username,
         email,
         password,
-        globalLang,
+        safeLang,
       );
       await (adminUser as any).asyncGrantAdmin();
 
       // create initial pages
-      await this.createInitialPages(globalLang, options?.serverDate);
+      await this.createInitialPages(safeLang, options?.serverDate);
 
       return adminUser;
     } catch (err) {

+ 21 - 0
apps/app/src/server/util/safe-path-utils.ts

@@ -1,4 +1,6 @@
+import { AllLang } from '@growi/core';
 import path from 'pathe';
+export { AllLang as SUPPORTED_LOCALES };
 
 /**
  * Validates that the given file path is within the base directory.
@@ -47,6 +49,25 @@ export function assertFileNameSafeForBaseDir(
   }
 }
 
+/**
+ * Resolves a locale-specific template path safely, preventing path traversal attacks.
+ * Falls back to 'en_US' if the locale is not in the supported list.
+ *
+ * @param locale - The locale string (e.g. 'en_US')
+ * @param baseDir - The base directory for locale files
+ * @param templateSubPath - The sub-path within the locale directory (e.g. 'notifications/event.ejs')
+ * @returns The template path
+ * @throws Error if path traversal is detected
+ */
+export function resolveLocalePath(
+  locale: string,
+  baseDir: string,
+  templateSubPath: string,
+): string {
+  const safeLocale = (AllLang as string[]).includes(locale) ? locale : 'en_US';
+  return path.join(baseDir, safeLocale, templateSubPath);
+}
+
 /**
  * Validates that joining baseDir with fileName results in a path within baseDir.
  * This is useful for validating user-provided file names before using them.

+ 15 - 0
packages/editor/src/client/services-internal/keymaps/default.ts

@@ -0,0 +1,15 @@
+import { Prec } from '@codemirror/state';
+import { keymap } from '@codemirror/view';
+
+import type { KeymapFactory } from './types';
+
+export const defaultKeymap: KeymapFactory = async () => {
+  const { defaultKeymap: cmDefaultKeymap } = await import(
+    '@codemirror/commands'
+  );
+  return {
+    extension: keymap.of(cmDefaultKeymap),
+    precedence: Prec.low,
+    overrides: [],
+  };
+};

+ 40 - 0
packages/editor/src/client/services-internal/keymaps/emacs/formatting.ts

@@ -0,0 +1,40 @@
+import type { EditorView } from '@codemirror/view';
+
+import { toggleMarkdownSymbol } from '../../markdown-utils';
+
+/**
+ * Register Emacs markdown-mode formatting commands and keybindings.
+ * Uses C-c C-s prefix following Emacs markdown-mode conventions.
+ */
+export const registerFormattingBindings = (
+  EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
+): void => {
+  EmacsHandler.addCommands({
+    markdownBold(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '**', '**');
+    },
+    markdownItalic(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '*', '*');
+    },
+    markdownCode(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '`', '`');
+    },
+    markdownStrikethrough(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '~~', '~~');
+    },
+    markdownCodeBlock(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '```\n', '\n```');
+    },
+  });
+
+  // C-c C-s b / C-c C-s B → Bold
+  // C-c C-s i / C-c C-s I → Italic
+  // C-c C-s c → Code (inline)
+  // C-c C-s s → Strikethrough
+  // C-c C-s p → Pre (code block)
+  EmacsHandler.bindKey('C-c C-s b|C-c C-s S-b', 'markdownBold');
+  EmacsHandler.bindKey('C-c C-s i|C-c C-s S-i', 'markdownItalic');
+  EmacsHandler.bindKey('C-c C-s c', 'markdownCode');
+  EmacsHandler.bindKey('C-c C-s s', 'markdownStrikethrough');
+  EmacsHandler.bindKey('C-c C-s p', 'markdownCodeBlock');
+};

+ 30 - 0
packages/editor/src/client/services-internal/keymaps/emacs/index.ts

@@ -0,0 +1,30 @@
+import { Prec } from '@codemirror/state';
+
+import type { KeymapFactory } from '../types';
+import { registerFormattingBindings } from './formatting';
+import { registerNavigationBindings } from './navigation';
+import { registerStructuralBindings } from './structural';
+
+export const emacsKeymap: KeymapFactory = async (onSave) => {
+  const { EmacsHandler, emacs } = await import('@replit/codemirror-emacs');
+
+  registerFormattingBindings(EmacsHandler);
+  registerStructuralBindings(EmacsHandler);
+  registerNavigationBindings(EmacsHandler);
+
+  // C-x C-s → Save
+  if (onSave != null) {
+    EmacsHandler.addCommands({
+      save() {
+        onSave();
+      },
+    });
+    EmacsHandler.bindKey('C-x C-s', 'save');
+  }
+
+  return {
+    extension: emacs(),
+    precedence: Prec.high,
+    overrides: ['formatting', 'structural'],
+  };
+};

+ 251 - 0
packages/editor/src/client/services-internal/keymaps/emacs/navigation.ts

@@ -0,0 +1,251 @@
+import type { EditorView } from '@codemirror/view';
+
+const HEADING_RE = /^(#{1,6})\s/;
+
+const findHeading = (
+  view: EditorView,
+  from: number,
+  direction: 'forward' | 'backward',
+  levelFilter?: number,
+): number | null => {
+  const doc = view.state.doc;
+  const startLine = doc.lineAt(from).number;
+
+  if (direction === 'forward') {
+    for (let i = startLine + 1; i <= doc.lines; i++) {
+      const line = doc.line(i);
+      const match = line.text.match(HEADING_RE);
+      if (match && (levelFilter == null || match[1].length === levelFilter)) {
+        return line.from;
+      }
+    }
+  } else {
+    for (let i = startLine - 1; i >= 1; i--) {
+      const line = doc.line(i);
+      const match = line.text.match(HEADING_RE);
+      if (match && (levelFilter == null || match[1].length === levelFilter)) {
+        return line.from;
+      }
+    }
+  }
+  return null;
+};
+
+const getCurrentHeadingLevel = (view: EditorView): number | null => {
+  const doc = view.state.doc;
+  const curLine = doc.lineAt(view.state.selection.main.head).number;
+
+  for (let i = curLine; i >= 1; i--) {
+    const line = doc.line(i);
+    const match = line.text.match(HEADING_RE);
+    if (match) return match[1].length;
+  }
+  return null;
+};
+
+/**
+ * Register Emacs markdown-mode navigation and extended editing commands.
+ */
+export const registerNavigationBindings = (
+  EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
+): void => {
+  EmacsHandler.addCommands({
+    markdownNextHeading(handler: { view: EditorView }) {
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'forward',
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownPrevHeading(handler: { view: EditorView }) {
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'backward',
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownNextSiblingHeading(handler: { view: EditorView }) {
+      const level = getCurrentHeadingLevel(handler.view);
+      if (level == null) return;
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'forward',
+        level,
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownPrevSiblingHeading(handler: { view: EditorView }) {
+      const level = getCurrentHeadingLevel(handler.view);
+      if (level == null) return;
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'backward',
+        level,
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownUpHeading(handler: { view: EditorView }) {
+      const level = getCurrentHeadingLevel(handler.view);
+      if (level == null || level <= 1) return;
+      const parentLevel = level - 1;
+      const pos = findHeading(
+        handler.view,
+        handler.view.state.selection.main.head,
+        'backward',
+        parentLevel,
+      );
+      if (pos != null) {
+        handler.view.dispatch({ selection: { anchor: pos } });
+      }
+    },
+    markdownPromote(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const headingMatch = line.text.match(HEADING_RE);
+      if (headingMatch && headingMatch[1].length > 1) {
+        const newPrefix = '#'.repeat(headingMatch[1].length - 1);
+        view.dispatch({
+          changes: {
+            from: line.from,
+            to: line.from + headingMatch[1].length,
+            insert: newPrefix,
+          },
+        });
+        return;
+      }
+      // List outdent
+      const listMatch = line.text.match(/^(\s{2,})([-*+]|\d+\.)\s/);
+      if (listMatch) {
+        const newIndent = listMatch[1].slice(2);
+        view.dispatch({
+          changes: {
+            from: line.from,
+            to: line.from + listMatch[1].length,
+            insert: newIndent,
+          },
+        });
+      }
+    },
+    markdownDemote(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const headingMatch = line.text.match(HEADING_RE);
+      if (headingMatch && headingMatch[1].length < 6) {
+        const newPrefix = '#'.repeat(headingMatch[1].length + 1);
+        view.dispatch({
+          changes: {
+            from: line.from,
+            to: line.from + headingMatch[1].length,
+            insert: newPrefix,
+          },
+        });
+        return;
+      }
+      // List indent
+      const listMatch = line.text.match(/^(\s*)([-*+]|\d+\.)\s/);
+      if (listMatch) {
+        view.dispatch({
+          changes: { from: line.from, insert: '  ' },
+        });
+      }
+    },
+    markdownKill(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const text = line.text;
+
+      // Copy to clipboard
+      if (typeof navigator !== 'undefined' && navigator.clipboard) {
+        navigator.clipboard.writeText(text).catch(() => {});
+      }
+
+      // Delete the line (including newline if not last line)
+      const from = line.from;
+      const to =
+        line.number < view.state.doc.lines
+          ? line.to + 1
+          : line.from > 0
+            ? line.from - 1
+            : line.to;
+      view.dispatch({
+        changes: { from, to, insert: '' },
+      });
+    },
+    markdownImage(handler: { view: EditorView }) {
+      toggleMarkdownImageSymbol(handler.view);
+    },
+    markdownTable(handler: { view: EditorView }) {
+      const { view } = handler;
+      const pos = view.state.selection.main.head;
+      const template =
+        '| Header | Header |\n| ------ | ------ |\n| Cell   | Cell   |';
+      view.dispatch({
+        changes: { from: pos, insert: template },
+        selection: { anchor: pos + 2 },
+      });
+    },
+    markdownFootnote(handler: { view: EditorView }) {
+      const { view } = handler;
+      const pos = view.state.selection.main.head;
+      const doc = view.state.doc;
+
+      // Find next available footnote number
+      let maxNum = 0;
+      for (let i = 1; i <= doc.lines; i++) {
+        const line = doc.line(i);
+        const matches = line.text.matchAll(/\[\^(\d+)\]/g);
+        for (const m of matches) {
+          const num = Number.parseInt(m[1], 10);
+          if (num > maxNum) maxNum = num;
+        }
+      }
+      const nextNum = maxNum + 1;
+
+      // Insert marker at cursor and definition at end of document
+      const marker = `[^${nextNum}]`;
+      const definition = `\n[^${nextNum}]: `;
+      view.dispatch({
+        changes: [
+          { from: pos, insert: marker },
+          { from: doc.length, insert: definition },
+        ],
+        selection: { anchor: pos + marker.length },
+      });
+    },
+  });
+
+  const toggleMarkdownImageSymbol = (view: EditorView): void => {
+    const { from, to } = view.state.selection.main;
+    const selectedText = view.state.sliceDoc(from, to);
+
+    const insert = `![${selectedText}]()`;
+    view.dispatch({
+      changes: { from, to, insert },
+      selection: { anchor: from + 2 + selectedText.length + 2 },
+    });
+  };
+
+  EmacsHandler.bindKey('C-c C--', 'markdownPromote');
+  EmacsHandler.bindKey('C-c C-=', 'markdownDemote');
+  EmacsHandler.bindKey('C-c C-n', 'markdownNextHeading');
+  EmacsHandler.bindKey('C-c C-p', 'markdownPrevHeading');
+  EmacsHandler.bindKey('C-c C-f', 'markdownNextSiblingHeading');
+  EmacsHandler.bindKey('C-c C-b', 'markdownPrevSiblingHeading');
+  EmacsHandler.bindKey('C-c C-u', 'markdownUpHeading');
+  EmacsHandler.bindKey('C-c C-k', 'markdownKill');
+  EmacsHandler.bindKey('C-c C-i', 'markdownImage');
+  EmacsHandler.bindKey('C-c C-s t', 'markdownTable');
+  EmacsHandler.bindKey('C-c C-s f', 'markdownFootnote');
+};

+ 108 - 0
packages/editor/src/client/services-internal/keymaps/emacs/structural.ts

@@ -0,0 +1,108 @@
+import type { EditorView } from '@codemirror/view';
+
+import { insertLinePrefix, toggleMarkdownSymbol } from '../../markdown-utils';
+
+/**
+ * Register Emacs markdown-mode structural editing commands and keybindings.
+ * Covers headings, blockquote, link, horizontal rule, list items, and fenced code blocks.
+ */
+export const registerStructuralBindings = (
+  EmacsHandler: typeof import('@replit/codemirror-emacs').EmacsHandler,
+): void => {
+  EmacsHandler.addCommands({
+    markdownBlockquote(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '>');
+    },
+    markdownLink(handler: { view: EditorView }) {
+      toggleMarkdownSymbol(handler.view, '[', ']()');
+    },
+    markdownHorizontalRule(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const insert = line.text.trim() === '' ? '---' : '\n---\n';
+      view.dispatch({
+        changes: { from: line.from, to: line.to, insert },
+        selection: { anchor: line.from + insert.length },
+      });
+    },
+    markdownHeadingDwim(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const match = line.text.match(/^(#{1,6})\s/);
+      const currentLevel = match ? match[1].length : 0;
+      const nextLevel = currentLevel >= 6 ? 1 : currentLevel + 1;
+      const prefix = '#'.repeat(nextLevel);
+      const content = line.text.replace(/^#{1,6}\s*/, '');
+      view.dispatch({
+        changes: {
+          from: line.from,
+          to: line.to,
+          insert: `${prefix} ${content}`,
+        },
+        selection: { anchor: line.from + prefix.length + 1 + content.length },
+      });
+    },
+    markdownHeading1(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '#', true);
+    },
+    markdownHeading2(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '##', true);
+    },
+    markdownHeading3(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '###', true);
+    },
+    markdownHeading4(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '####', true);
+    },
+    markdownHeading5(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '#####', true);
+    },
+    markdownHeading6(handler: { view: EditorView }) {
+      insertLinePrefix(handler.view, '######', true);
+    },
+    markdownNewListItem(handler: { view: EditorView }) {
+      const { view } = handler;
+      const line = view.state.doc.lineAt(view.state.selection.main.head);
+      const bulletMatch = line.text.match(/^(\s*)([-*+])\s/);
+      const numberedMatch = line.text.match(/^(\s*)(\d+)\.\s/);
+
+      let insert: string;
+      if (bulletMatch) {
+        insert = `\n${bulletMatch[1]}${bulletMatch[2]} `;
+      } else if (numberedMatch) {
+        const nextNum = Number.parseInt(numberedMatch[2], 10) + 1;
+        insert = `\n${numberedMatch[1]}${nextNum}. `;
+      } else {
+        insert = '\n- ';
+      }
+
+      view.dispatch({
+        changes: { from: line.to, insert },
+        selection: { anchor: line.to + insert.length },
+      });
+    },
+    markdownFencedCodeBlock(handler: { view: EditorView }) {
+      const { view } = handler;
+      const { from, to } = view.state.selection.main;
+      const selectedText = view.state.sliceDoc(from, to);
+      const insert = `\`\`\`\n${selectedText}\n\`\`\``;
+      view.dispatch({
+        changes: { from, to, insert },
+        selection: { anchor: from + 3 },
+      });
+    },
+  });
+
+  EmacsHandler.bindKey('C-c C-s q', 'markdownBlockquote');
+  EmacsHandler.bindKey('C-c C-l', 'markdownLink');
+  EmacsHandler.bindKey('C-c C-s -', 'markdownHorizontalRule');
+  EmacsHandler.bindKey('C-c C-s h', 'markdownHeadingDwim');
+  EmacsHandler.bindKey('C-c C-s 1', 'markdownHeading1');
+  EmacsHandler.bindKey('C-c C-s 2', 'markdownHeading2');
+  EmacsHandler.bindKey('C-c C-s 3', 'markdownHeading3');
+  EmacsHandler.bindKey('C-c C-s 4', 'markdownHeading4');
+  EmacsHandler.bindKey('C-c C-s 5', 'markdownHeading5');
+  EmacsHandler.bindKey('C-c C-s 6', 'markdownHeading6');
+  EmacsHandler.bindKey('C-c C-j', 'markdownNewListItem');
+  EmacsHandler.bindKey('C-c C-s S-c', 'markdownFencedCodeBlock');
+};

+ 8 - 9
packages/editor/src/client/services-internal/keymaps/index.ts

@@ -1,21 +1,20 @@
-import type { Extension } from '@codemirror/state';
-import { keymap } from '@codemirror/view';
-
 import type { KeyMapMode } from '../../../consts';
+import type { KeymapResult } from './types';
+
+export type { KeymapFactory, KeymapResult, ShortcutCategory } from './types';
 
 export const getKeymap = async (
   keyMapName?: KeyMapMode,
   onSave?: () => void,
-): Promise<Extension> => {
+): Promise<KeymapResult> => {
   switch (keyMapName) {
     case 'vim':
       return (await import('./vim')).vimKeymap(onSave);
     case 'emacs':
-      return (await import('@replit/codemirror-emacs')).emacs();
+      return (await import('./emacs')).emacsKeymap(onSave);
     case 'vscode':
-      return keymap.of(
-        (await import('@replit/codemirror-vscode-keymap')).vscodeKeymap,
-      );
+      return (await import('./vscode')).vscodeKeymap();
+    default:
+      return (await import('./default')).defaultKeymap();
   }
-  return keymap.of((await import('@codemirror/commands')).defaultKeymap);
 };

+ 48 - 0
packages/editor/src/client/services-internal/keymaps/keymap-factories.spec.ts

@@ -0,0 +1,48 @@
+// @vitest-environment jsdom
+import { Prec } from '@codemirror/state';
+import { describe, expect, it, vi } from 'vitest';
+
+import { getKeymap } from './index';
+
+describe('getKeymap', () => {
+  it('should return low precedence and no overrides for default mode', async () => {
+    const result = await getKeymap();
+    expect(result.extension).toBeDefined();
+    expect(result.precedence).toBe(Prec.low);
+    expect(result.overrides).toEqual([]);
+  });
+
+  it('should return low precedence and no overrides for vscode mode', async () => {
+    const result = await getKeymap('vscode');
+    expect(result.extension).toBeDefined();
+    expect(result.precedence).toBe(Prec.low);
+    expect(result.overrides).toEqual([]);
+  });
+
+  it('should return high precedence and no overrides for vim mode', async () => {
+    const result = await getKeymap('vim');
+    expect(result.extension).toBeDefined();
+    expect(result.precedence).toBe(Prec.high);
+    expect(result.overrides).toEqual([]);
+  });
+
+  it('should return high precedence with formatting and structural overrides for emacs mode', async () => {
+    const result = await getKeymap('emacs');
+    expect(result.extension).toBeDefined();
+    expect(result.precedence).toBe(Prec.high);
+    expect(result.overrides).toContain('formatting');
+    expect(result.overrides).toContain('structural');
+  });
+
+  it('should pass onSave to vim mode and register :w command', async () => {
+    const onSave = vi.fn();
+    const result = await getKeymap('vim', onSave);
+    expect(result.extension).toBeDefined();
+  });
+
+  it('should pass onSave to emacs mode for C-x C-s binding', async () => {
+    const onSave = vi.fn();
+    const result = await getKeymap('emacs', onSave);
+    expect(result.extension).toBeDefined();
+  });
+});

+ 11 - 0
packages/editor/src/client/services-internal/keymaps/types.ts

@@ -0,0 +1,11 @@
+import type { Extension } from '@codemirror/state';
+
+export type ShortcutCategory = 'formatting' | 'structural' | 'navigation';
+
+export interface KeymapResult {
+  readonly extension: Extension;
+  readonly precedence: (ext: Extension) => Extension;
+  readonly overrides: readonly ShortcutCategory[];
+}
+
+export type KeymapFactory = (onSave?: () => void) => Promise<KeymapResult>;

+ 19 - 7
packages/editor/src/client/services-internal/keymaps/vim.ts

@@ -1,13 +1,25 @@
-import type { Extension } from '@codemirror/state';
-import { Vim, vim } from '@replit/codemirror-vim';
+import { Prec } from '@codemirror/state';
 
-// vim useful keymap custom
-Vim.map('jj', '<Esc>', 'insert');
-Vim.map('jk', '<Esc>', 'insert');
+import type { KeymapFactory } from './types';
+
+let initialized = false;
+
+export const vimKeymap: KeymapFactory = async (onSave) => {
+  const { Vim, vim } = await import('@replit/codemirror-vim');
+
+  if (!initialized) {
+    Vim.map('jj', '<Esc>', 'insert');
+    Vim.map('jk', '<Esc>', 'insert');
+    initialized = true;
+  }
 
-export const vimKeymap = (onSave?: () => void): Extension => {
   if (onSave != null) {
     Vim.defineEx('write', 'w', onSave);
   }
-  return vim();
+
+  return {
+    extension: vim(),
+    precedence: Prec.high,
+    overrides: [],
+  };
 };

+ 15 - 0
packages/editor/src/client/services-internal/keymaps/vscode.ts

@@ -0,0 +1,15 @@
+import { Prec } from '@codemirror/state';
+import { keymap } from '@codemirror/view';
+
+import type { KeymapFactory } from './types';
+
+export const vscodeKeymap: KeymapFactory = async () => {
+  const { vscodeKeymap: cmVscodeKeymap } = await import(
+    '@replit/codemirror-vscode-keymap'
+  );
+  return {
+    extension: keymap.of(cmVscodeKeymap),
+    precedence: Prec.low,
+    overrides: [],
+  };
+};

+ 2 - 0
packages/editor/src/client/services-internal/markdown-utils/index.ts

@@ -0,0 +1,2 @@
+export { insertLinePrefix } from './insert-line-prefix';
+export { toggleMarkdownSymbol } from './toggle-markdown-symbol';

+ 59 - 0
packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.spec.ts

@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+import { EditorSelection, EditorState } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { describe, expect, it } from 'vitest';
+
+import { insertLinePrefix } from './insert-line-prefix';
+
+const createView = (doc: string, anchor: number, head?: number): EditorView => {
+  const state = EditorState.create({
+    doc,
+    selection: EditorSelection.create([
+      EditorSelection.range(anchor, head ?? anchor),
+    ]),
+  });
+  return new EditorView({ state });
+};
+
+describe('insertLinePrefix', () => {
+  it('should add prefix to a single line', () => {
+    const view = createView('hello', 0, 5);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe('> hello');
+  });
+
+  it('should add prefix to an empty line', () => {
+    const view = createView('', 0);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe('> ');
+  });
+
+  it('should add prefix to multiple lines', () => {
+    const doc = 'line one\nline two\nline three';
+    const view = createView(doc, 0, doc.length);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe(
+      '> line one\n> line two\n> line three',
+    );
+  });
+
+  it('should remove prefix when all non-empty lines already have it', () => {
+    const doc = '> line one\n> line two';
+    const view = createView(doc, 0, doc.length);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe('line one\nline two');
+  });
+
+  it('should skip empty lines when adding prefix', () => {
+    const doc = 'line one\n\nline three';
+    const view = createView(doc, 0, doc.length);
+    insertLinePrefix(view, '>');
+    expect(view.state.doc.toString()).toBe('> line one\n\n> line three');
+  });
+
+  it('should handle heading prefix (#)', () => {
+    const view = createView('hello', 0, 5);
+    insertLinePrefix(view, '#');
+    expect(view.state.doc.toString()).toBe('# hello');
+  });
+});

+ 164 - 0
packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.ts

@@ -0,0 +1,164 @@
+import type { ChangeSpec, Line, Text } from '@codemirror/state';
+import type { EditorView } from '@codemirror/view';
+
+// https://regex101.com/r/5ILXUX/1
+const LEADING_SPACES = /^\s*/;
+// https://regex101.com/r/ScAXzy/1
+const createPrefixPattern = (prefix: string) =>
+  new RegExp(`^\\s*(${prefix}+)\\s*`);
+
+const removePrefix = (text: string, prefix: string): string => {
+  if (text.startsWith(prefix)) {
+    return text.slice(prefix.length).trimStart();
+  }
+  return text;
+};
+
+const allLinesEmpty = (doc: Text, startLine: Line, endLine: Line): boolean => {
+  for (let i = startLine.number; i <= endLine.number; i++) {
+    const line = doc.line(i);
+    if (line.text.trim() !== '') {
+      return false;
+    }
+  }
+  return true;
+};
+
+const allLinesHavePrefix = (
+  doc: Text,
+  startLine: Line,
+  endLine: Line,
+  prefix: string,
+): boolean => {
+  let hasNonEmptyLine = false;
+
+  for (let i = startLine.number; i <= endLine.number; i++) {
+    const line = doc.line(i);
+    const trimmedLine = line.text.trim();
+
+    if (trimmedLine !== '') {
+      hasNonEmptyLine = true;
+      if (!trimmedLine.startsWith(prefix)) {
+        return false;
+      }
+    }
+  }
+
+  return hasNonEmptyLine;
+};
+
+/**
+ * Insert or toggle a prefix at the beginning of the current line(s).
+ * Handles multi-line selections. Removes prefix if all lines already have it.
+ */
+export const insertLinePrefix = (
+  view: EditorView,
+  prefix: string,
+  noSpaceIfPrefixExists = false,
+): void => {
+  const { from, to } = view.state.selection.main;
+  const doc = view.state.doc;
+  const startLine = doc.lineAt(from);
+  const endLine = doc.lineAt(to);
+
+  const changes: ChangeSpec[] = [];
+  let totalLengthChange = 0;
+
+  const isPrefixRemoval = allLinesHavePrefix(doc, startLine, endLine, prefix);
+
+  if (allLinesEmpty(doc, startLine, endLine)) {
+    for (let i = startLine.number; i <= endLine.number; i++) {
+      const line = view.state.doc.line(i);
+      const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
+      const insertText = `${leadingSpaces}${prefix} `;
+
+      const change = {
+        from: line.from,
+        to: line.to,
+        insert: insertText,
+      };
+
+      changes.push(change);
+      totalLengthChange += insertText.length - (line.to - line.from);
+    }
+
+    view.dispatch({ changes });
+    view.dispatch({
+      selection: {
+        anchor: from + totalLengthChange,
+        head: to + totalLengthChange,
+      },
+    });
+    view.focus();
+    return;
+  }
+
+  for (let i = startLine.number; i <= endLine.number; i++) {
+    const line = view.state.doc.line(i);
+    const trimmedLine = line.text.trim();
+    const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
+    const contentTrimmed = line.text.trimStart();
+
+    if (trimmedLine === '') {
+      continue;
+    }
+
+    let newLine = '';
+    let lengthChange = 0;
+
+    if (isPrefixRemoval) {
+      const prefixPattern = createPrefixPattern(prefix);
+      const contentStartMatch = line.text.match(prefixPattern);
+
+      if (contentStartMatch) {
+        if (noSpaceIfPrefixExists) {
+          const existingPrefixes = contentStartMatch[1];
+          const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
+          const newIndent = ' '.repeat(indentLevel);
+          newLine = `${newIndent}${existingPrefixes}${prefix} ${line.text.slice(contentStartMatch[0].length)}`;
+        } else {
+          const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
+          const newIndent = ' '.repeat(indentLevel);
+          const prefixRemovedText = removePrefix(contentTrimmed, prefix);
+          newLine = `${newIndent}${prefixRemovedText}`;
+        }
+
+        lengthChange = newLine.length - (line.to - line.from);
+
+        changes.push({
+          from: line.from,
+          to: line.to,
+          insert: newLine,
+        });
+      }
+    } else {
+      if (noSpaceIfPrefixExists && contentTrimmed.startsWith(prefix)) {
+        newLine = `${leadingSpaces}${prefix}${contentTrimmed}`;
+      } else {
+        newLine = `${leadingSpaces}${prefix} ${contentTrimmed}`;
+      }
+
+      lengthChange = newLine.length - (line.to - line.from);
+
+      changes.push({
+        from: line.from,
+        to: line.to,
+        insert: newLine,
+      });
+    }
+
+    totalLengthChange += lengthChange;
+  }
+
+  if (changes.length > 0) {
+    view.dispatch({ changes });
+
+    view.dispatch({
+      selection: {
+        anchor: from,
+        head: to + totalLengthChange,
+      },
+    });
+    view.focus();
+  }
+};

+ 61 - 0
packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.spec.ts

@@ -0,0 +1,61 @@
+// @vitest-environment jsdom
+import { EditorSelection, EditorState } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { describe, expect, it } from 'vitest';
+
+import { toggleMarkdownSymbol } from './toggle-markdown-symbol';
+
+const createView = (doc: string, anchor: number, head?: number): EditorView => {
+  const state = EditorState.create({
+    doc,
+    selection: EditorSelection.create([
+      EditorSelection.range(anchor, head ?? anchor),
+    ]),
+  });
+  return new EditorView({ state });
+};
+
+describe('toggleMarkdownSymbol', () => {
+  it('should wrap selected text with prefix and suffix', () => {
+    const view = createView('hello world', 0, 5);
+    toggleMarkdownSymbol(view, '**', '**');
+    expect(view.state.doc.toString()).toBe('**hello** world');
+  });
+
+  it('should unwrap text already wrapped with prefix and suffix', () => {
+    const view = createView('**hello** world', 0, 9);
+    toggleMarkdownSymbol(view, '**', '**');
+    expect(view.state.doc.toString()).toBe('hello world');
+  });
+
+  it('should insert prefix+suffix and place cursor between them when no selection', () => {
+    const view = createView('hello world', 5);
+    toggleMarkdownSymbol(view, '**', '**');
+    expect(view.state.doc.toString()).toBe('hello**** world');
+    expect(view.state.selection.main.head).toBe(7);
+  });
+
+  it('should handle single-char symbols (backtick)', () => {
+    const view = createView('code', 0, 4);
+    toggleMarkdownSymbol(view, '`', '`');
+    expect(view.state.doc.toString()).toBe('`code`');
+  });
+
+  it('should unwrap single-char symbols', () => {
+    const view = createView('`code`', 0, 6);
+    toggleMarkdownSymbol(view, '`', '`');
+    expect(view.state.doc.toString()).toBe('code');
+  });
+
+  it('should handle multiline prefix/suffix (code block)', () => {
+    const view = createView('some code', 0, 9);
+    toggleMarkdownSymbol(view, '```\n', '\n```');
+    expect(view.state.doc.toString()).toBe('```\nsome code\n```');
+  });
+
+  it('should handle asymmetric prefix and suffix (link)', () => {
+    const view = createView('text', 0, 4);
+    toggleMarkdownSymbol(view, '[', ']()');
+    expect(view.state.doc.toString()).toBe('[text]()');
+  });
+});

+ 33 - 0
packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.ts

@@ -0,0 +1,33 @@
+import type { EditorView } from '@codemirror/view';
+
+/**
+ * Toggle markdown symbols around the current selection.
+ * If the selection is already wrapped with prefix/suffix, remove them.
+ * If no text is selected, insert prefix+suffix and position cursor between them.
+ */
+export const toggleMarkdownSymbol = (
+  view: EditorView,
+  prefix: string,
+  suffix: string,
+): void => {
+  const { from, to, head } = view.state.selection.main;
+  const selectedText = view.state.sliceDoc(from, to);
+
+  let insertText: string;
+  if (selectedText.startsWith(prefix) && selectedText.endsWith(suffix)) {
+    insertText = selectedText.slice(prefix.length, -suffix.length || undefined);
+  } else {
+    insertText = prefix + selectedText + suffix;
+  }
+
+  const selection =
+    from === to
+      ? { anchor: from + prefix.length }
+      : { anchor: from, head: from + insertText.length };
+
+  const transaction = view.state.replaceSelection(insertText);
+  if (head == null) return;
+  view.dispatch(transaction);
+  view.dispatch({ selection });
+  view.focus();
+};

+ 3 - 42
packages/editor/src/client/services/use-codemirror-editor/utils/insert-markdown-elements.ts

@@ -1,21 +1,9 @@
 import { useCallback } from 'react';
 import type { EditorView } from '@codemirror/view';
 
-export type InsertMarkdownElements = (prefix: string, suffix: string) => void;
-
-const removeSymbol = (text: string, prefix: string, suffix: string): string => {
-  let result = text;
-
-  if (result.startsWith(prefix)) {
-    result = result.slice(prefix.length);
-  }
-
-  if (result.endsWith(suffix)) {
-    result = result.slice(0, -suffix.length);
-  }
+import { toggleMarkdownSymbol } from '../../../services-internal/markdown-utils';
 
-  return result;
-};
+export type InsertMarkdownElements = (prefix: string, suffix: string) => void;
 
 export const useInsertMarkdownElements = (
   view?: EditorView,
@@ -23,34 +11,7 @@ export const useInsertMarkdownElements = (
   return useCallback(
     (prefix, suffix) => {
       if (view == null) return;
-
-      const from = view?.state.selection.main.from;
-      const to = view?.state.selection.main.to;
-
-      const selectedText = view?.state.sliceDoc(from, to);
-      const cursorPos = view?.state.selection.main.head;
-
-      let insertText: string;
-
-      if (selectedText?.startsWith(prefix) && selectedText?.endsWith(suffix)) {
-        insertText = removeSymbol(selectedText, prefix, suffix);
-      } else {
-        insertText = prefix + selectedText + suffix;
-      }
-
-      const selection =
-        from === to
-          ? { anchor: from + prefix.length }
-          : { anchor: from, head: from + insertText.length };
-
-      const transaction = view?.state.replaceSelection(insertText);
-
-      if (transaction == null || cursorPos == null) {
-        return;
-      }
-      view?.dispatch(transaction);
-      view?.dispatch({ selection });
-      view?.focus();
+      toggleMarkdownSymbol(view, prefix, suffix);
     },
     [view],
   );

+ 4 - 161
packages/editor/src/client/services/use-codemirror-editor/utils/insert-prefix.ts

@@ -1,175 +1,18 @@
 import { useCallback } from 'react';
-import type { ChangeSpec, Line, Text } from '@codemirror/state';
 import type { EditorView } from '@codemirror/view';
 
+import { insertLinePrefix } from '../../../services-internal/markdown-utils';
+
 export type InsertPrefix = (
   prefix: string,
   noSpaceIfPrefixExists?: boolean,
 ) => void;
 
-// https:// regex101.com/r/5ILXUX/1
-const LEADING_SPACES = /^\s*/;
-// https://regex101.com/r/ScAXzy/1
-const createPrefixPattern = (prefix: string) =>
-  new RegExp(`^\\s*(${prefix}+)\\s*`);
-
-const removePrefix = (text: string, prefix: string): string => {
-  if (text.startsWith(prefix)) {
-    return text.slice(prefix.length).trimStart();
-  }
-  return text;
-};
-
-const allLinesEmpty = (doc: Text, startLine: Line, endLine: Line) => {
-  for (let i = startLine.number; i <= endLine.number; i++) {
-    const line = doc.line(i);
-    if (line.text.trim() !== '') {
-      return false;
-    }
-  }
-  return true;
-};
-
-const allLinesHavePrefix = (
-  doc: Text,
-  startLine: Line,
-  endLine: Line,
-  prefix: string,
-) => {
-  let hasNonEmptyLine = false;
-
-  for (let i = startLine.number; i <= endLine.number; i++) {
-    const line = doc.line(i);
-    const trimmedLine = line.text.trim();
-
-    if (trimmedLine !== '') {
-      hasNonEmptyLine = true;
-      if (!trimmedLine.startsWith(prefix)) {
-        return false;
-      }
-    }
-  }
-
-  return hasNonEmptyLine;
-};
-
 export const useInsertPrefix = (view?: EditorView): InsertPrefix => {
   return useCallback(
     (prefix: string, noSpaceIfPrefixExists = false) => {
-      if (view == null) {
-        return;
-      }
-
-      const { from, to } = view.state.selection.main;
-      const doc = view.state.doc;
-      const startLine = doc.lineAt(from);
-      const endLine = doc.lineAt(to);
-
-      const changes: ChangeSpec[] = [];
-      let totalLengthChange = 0;
-
-      const isPrefixRemoval = allLinesHavePrefix(
-        doc,
-        startLine,
-        endLine,
-        prefix,
-      );
-
-      if (allLinesEmpty(doc, startLine, endLine)) {
-        for (let i = startLine.number; i <= endLine.number; i++) {
-          const line = view.state.doc.line(i);
-          const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
-          const insertText = `${leadingSpaces}${prefix} `;
-
-          const change = {
-            from: line.from,
-            to: line.to,
-            insert: insertText,
-          };
-
-          changes.push(change);
-          totalLengthChange += insertText.length - (line.to - line.from);
-        }
-
-        view.dispatch({ changes });
-        view.dispatch({
-          selection: {
-            anchor: from + totalLengthChange,
-            head: to + totalLengthChange,
-          },
-        });
-        view.focus();
-        return;
-      }
-
-      for (let i = startLine.number; i <= endLine.number; i++) {
-        const line = view.state.doc.line(i);
-        const trimmedLine = line.text.trim();
-        const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
-        const contentTrimmed = line.text.trimStart();
-
-        if (trimmedLine === '') {
-          continue;
-        }
-
-        let newLine = '';
-        let lengthChange = 0;
-
-        if (isPrefixRemoval) {
-          const prefixPattern = createPrefixPattern(prefix);
-          const contentStartMatch = line.text.match(prefixPattern);
-
-          if (contentStartMatch) {
-            if (noSpaceIfPrefixExists) {
-              const existingPrefixes = contentStartMatch[1];
-              const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
-              const newIndent = ' '.repeat(indentLevel);
-              newLine = `${newIndent}${existingPrefixes}${prefix} ${line.text.slice(contentStartMatch[0].length)}`;
-            } else {
-              const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
-              const newIndent = ' '.repeat(indentLevel);
-              const prefixRemovedText = removePrefix(contentTrimmed, prefix);
-              newLine = `${newIndent}${prefixRemovedText}`;
-            }
-
-            lengthChange = newLine.length - (line.to - line.from);
-
-            changes.push({
-              from: line.from,
-              to: line.to,
-              insert: newLine,
-            });
-          }
-        } else {
-          if (noSpaceIfPrefixExists && contentTrimmed.startsWith(prefix)) {
-            newLine = `${leadingSpaces}${prefix}${contentTrimmed}`;
-          } else {
-            newLine = `${leadingSpaces}${prefix} ${contentTrimmed}`;
-          }
-
-          lengthChange = newLine.length - (line.to - line.from);
-
-          changes.push({
-            from: line.from,
-            to: line.to,
-            insert: newLine,
-          });
-        }
-
-        totalLengthChange += lengthChange;
-      }
-
-      if (changes.length > 0) {
-        view.dispatch({ changes });
-
-        view.dispatch({
-          selection: {
-            anchor: from,
-            head: to + totalLengthChange,
-          },
-        });
-        view.focus();
-      }
+      if (view == null) return;
+      insertLinePrefix(view, prefix, noSpaceIfPrefixExists);
     },
     [view],
   );

+ 15 - 8
packages/editor/src/client/stores/use-editor-settings.ts

@@ -17,6 +17,7 @@ import {
   insertNewRowToMarkdownTable,
   isInTable,
 } from '../services-internal';
+import type { KeymapResult } from '../services-internal/keymaps';
 import { useEditorShortcuts } from './use-editor-shortcuts';
 
 const useStyleActiveLine = (
@@ -88,8 +89,8 @@ const useKeymapExtension = (
   codeMirrorEditor?: UseCodeMirrorEditor,
   keymapMode?: KeyMapMode,
   onSave?: () => void,
-): void => {
-  const [keymapExtension, setKeymapExtension] = useState<Extension | undefined>(
+): KeymapResult | undefined => {
+  const [keymapResult, setKeymapResult] = useState<KeymapResult | undefined>(
     undefined,
   );
 
@@ -104,20 +105,22 @@ const useKeymapExtension = (
     const settingKeyMap = async (name?: KeyMapMode) => {
       // Pass a stable wrapper function that delegates to the ref
       const stableOnSave = () => onSaveRef.current?.();
-      setKeymapExtension(await getKeymap(name, stableOnSave));
+      setKeymapResult(await getKeymap(name, stableOnSave));
     };
     settingKeyMap(keymapMode);
   }, [keymapMode]);
 
   useEffect(() => {
-    if (keymapExtension == null) {
+    if (keymapResult == null) {
       return;
     }
     const cleanupFunction = codeMirrorEditor?.appendExtensions(
-      Prec.low(keymapExtension),
+      keymapResult.precedence(keymapResult.extension),
     );
     return cleanupFunction;
-  }, [codeMirrorEditor, keymapExtension]);
+  }, [codeMirrorEditor, keymapResult]);
+
+  return keymapResult;
 };
 
 export const useEditorSettings = (
@@ -125,9 +128,13 @@ export const useEditorSettings = (
   editorSettings?: EditorSettings,
   onSave?: () => void,
 ): void => {
-  useEditorShortcuts(codeMirrorEditor, editorSettings?.keymapMode);
+  const keymapResult = useKeymapExtension(
+    codeMirrorEditor,
+    editorSettings?.keymapMode,
+    onSave,
+  );
+  useEditorShortcuts(codeMirrorEditor, keymapResult?.overrides);
   useStyleActiveLine(codeMirrorEditor, editorSettings?.styleActiveLine);
   useEnterKeyHandler(codeMirrorEditor, editorSettings?.autoFormatMarkdownTable);
   useThemeExtension(codeMirrorEditor, editorSettings?.theme);
-  useKeymapExtension(codeMirrorEditor, editorSettings?.keymapMode, onSave);
 };

+ 45 - 20
packages/editor/src/client/stores/use-editor-shortcuts.ts

@@ -2,7 +2,6 @@ import { useEffect } from 'react';
 import type { EditorView } from '@codemirror/view';
 import { type KeyBinding, keymap } from '@codemirror/view';
 
-import type { KeyMapMode } from '../../consts';
 import type { UseCodeMirrorEditor } from '../services';
 import { useAddMultiCursorKeyBindings } from '../services/use-codemirror-editor/utils/editor-shortcuts/add-multi-cursor';
 import { useInsertBlockquoteKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/insert-blockquote';
@@ -14,45 +13,71 @@ import { useMakeTextCodeKeyBinding } from '../services/use-codemirror-editor/uti
 import { useMakeCodeBlockExtension } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-code-block';
 import { useMakeTextItalicKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-italic';
 import { useMakeTextStrikethroughKeyBinding } from '../services/use-codemirror-editor/utils/editor-shortcuts/make-text-strikethrough';
+import type { ShortcutCategory } from '../services-internal/keymaps';
+
+interface CategorizedKeyBindings {
+  readonly category: ShortcutCategory | null;
+  readonly bindings: readonly KeyBinding[];
+}
 
 const useKeyBindings = (
   view?: EditorView,
-  keymapModeName?: KeyMapMode,
+  overrides?: readonly ShortcutCategory[],
 ): KeyBinding[] => {
-  const makeTextBoldKeyBinding = useMakeTextBoldKeyBinding(
-    view,
-    keymapModeName,
-  );
+  // Formatting keybindings
+  const makeTextBoldKeyBinding = useMakeTextBoldKeyBinding(view);
   const makeTextItalicKeyBinding = useMakeTextItalicKeyBinding(view);
   const makeTextStrikethroughKeyBinding =
     useMakeTextStrikethroughKeyBinding(view);
   const makeTextCodeCommand = useMakeTextCodeKeyBinding(view);
+
+  // Structural keybindings
   const insertNumberedKeyBinding = useInsertNumberedKeyBinding(view);
   const insertBulletListKeyBinding = useInsertBulletListKeyBinding(view);
   const insertBlockquoteKeyBinding = useInsertBlockquoteKeyBinding(view);
-  const InsertLinkKeyBinding = useInsertLinkKeyBinding(view);
+  const insertLinkKeyBinding = useInsertLinkKeyBinding(view);
+
+  // Always-on keybindings
   const multiCursorKeyBindings = useAddMultiCursorKeyBindings();
 
-  const keyBindings: KeyBinding[] = [
-    makeTextBoldKeyBinding,
-    makeTextItalicKeyBinding,
-    makeTextStrikethroughKeyBinding,
-    makeTextCodeCommand,
-    insertNumberedKeyBinding,
-    insertBulletListKeyBinding,
-    insertBlockquoteKeyBinding,
-    InsertLinkKeyBinding,
-    ...multiCursorKeyBindings,
+  const allGroups: CategorizedKeyBindings[] = [
+    {
+      category: 'formatting',
+      bindings: [
+        makeTextBoldKeyBinding,
+        makeTextItalicKeyBinding,
+        makeTextStrikethroughKeyBinding,
+        makeTextCodeCommand,
+      ],
+    },
+    {
+      category: 'structural',
+      bindings: [
+        insertNumberedKeyBinding,
+        insertBulletListKeyBinding,
+        insertBlockquoteKeyBinding,
+        insertLinkKeyBinding,
+      ],
+    },
+    {
+      category: null,
+      bindings: multiCursorKeyBindings,
+    },
   ];
 
-  return keyBindings;
+  return allGroups
+    .filter(
+      (group) =>
+        group.category === null || !overrides?.includes(group.category),
+    )
+    .flatMap((group) => [...group.bindings]);
 };
 
 export const useEditorShortcuts = (
   codeMirrorEditor?: UseCodeMirrorEditor,
-  keymapModeName?: KeyMapMode,
+  overrides?: readonly ShortcutCategory[],
 ): void => {
-  const keyBindings = useKeyBindings(codeMirrorEditor?.view, keymapModeName);
+  const keyBindings = useKeyBindings(codeMirrorEditor?.view, overrides);
 
   // Since key combinations of 4 or more keys cannot be implemented with CodeMirror's keybinding, they are implemented as Extensions.
   const makeCodeBlockExtension = useMakeCodeBlockExtension();