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

Merge remote-tracking branch 'origin/master' into feat/161862-180061-shortcuts-modal-other-link

VANELLOPE\tomoyuki-t 2 дней назад
Родитель
Сommit
b4525a34ce
78 измененных файлов с 4788 добавлено и 1108 удалено
  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. 598 0
      .kiro/specs/news-inappnotification/design.md
  16. 1 1
      .kiro/specs/news-inappnotification/requirements.md
  17. 142 0
      .kiro/specs/news-inappnotification/research.md
  18. 5 5
      .kiro/specs/news-inappnotification/spec.json
  19. 150 0
      .kiro/specs/news-inappnotification/tasks.md
  20. 0 14
      .kiro/specs/suggest-path/design.md
  21. 1 1
      .kiro/specs/suggest-path/requirements.md
  22. 1 1
      .kiro/specs/suggest-path/spec.json
  23. 0 2
      .kiro/specs/suggest-path/tasks.md
  24. 2 0
      apps/app/docker/Dockerfile.dockerignore
  25. 1 1
      apps/app/package.json
  26. 128 0
      apps/app/playwright/23-editor/emacs-keymap.spec.ts
  27. 9 2
      apps/app/resource/Contributor.js
  28. 4 3
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  29. 6 7
      apps/app/src/client/components/PageCreateModal.tsx
  30. 0 9
      apps/app/src/client/services/AdminAppContainer.js
  31. 27 0
      apps/app/src/server/middlewares/reject-link-sharing-disabled.ts
  32. 1 0
      apps/app/src/server/models/activity.ts
  33. 7 26
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  34. 12 4
      apps/app/src/server/routes/apiv3/forgot-password.js
  35. 119 0
      apps/app/src/server/routes/apiv3/page/get-page-by-share-link.ts
  36. 10 99
      apps/app/src/server/routes/apiv3/page/index.ts
  37. 224 0
      apps/app/src/server/routes/apiv3/page/respond-with-single-page.spec.ts
  38. 97 0
      apps/app/src/server/routes/apiv3/page/respond-with-single-page.ts
  39. 45 20
      apps/app/src/server/routes/apiv3/page/update-page.ts
  40. 12 6
      apps/app/src/server/routes/apiv3/user-activation.ts
  41. 14 9
      apps/app/src/server/routes/apiv3/users.js
  42. 7 5
      apps/app/src/server/routes/login.js
  43. 1 0
      apps/app/src/server/service/activity/index.ts
  44. 59 0
      apps/app/src/server/service/activity/update-activity-logic.ts
  45. 460 0
      apps/app/src/server/service/activity/update-activity.spec.ts
  46. 16 7
      apps/app/src/server/service/global-notification/global-notification-mail.ts
  47. 31 11
      apps/app/src/server/service/installer.ts
  48. 21 0
      apps/app/src/server/util/safe-path-utils.ts
  49. 5 5
      apps/app/src/states/page/use-fetch-current-page.spec.tsx
  50. 5 2
      apps/app/src/states/page/use-fetch-current-page.ts
  51. 1 1
      apps/slackbot-proxy/package.json
  52. 14 2
      package.json
  53. 15 0
      packages/editor/src/client/services-internal/keymaps/default.ts
  54. 40 0
      packages/editor/src/client/services-internal/keymaps/emacs/formatting.ts
  55. 30 0
      packages/editor/src/client/services-internal/keymaps/emacs/index.ts
  56. 251 0
      packages/editor/src/client/services-internal/keymaps/emacs/navigation.ts
  57. 108 0
      packages/editor/src/client/services-internal/keymaps/emacs/structural.ts
  58. 8 9
      packages/editor/src/client/services-internal/keymaps/index.ts
  59. 48 0
      packages/editor/src/client/services-internal/keymaps/keymap-factories.spec.ts
  60. 11 0
      packages/editor/src/client/services-internal/keymaps/types.ts
  61. 19 7
      packages/editor/src/client/services-internal/keymaps/vim.ts
  62. 15 0
      packages/editor/src/client/services-internal/keymaps/vscode.ts
  63. 2 0
      packages/editor/src/client/services-internal/markdown-utils/index.ts
  64. 59 0
      packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.spec.ts
  65. 164 0
      packages/editor/src/client/services-internal/markdown-utils/insert-line-prefix.ts
  66. 61 0
      packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.spec.ts
  67. 33 0
      packages/editor/src/client/services-internal/markdown-utils/toggle-markdown-symbol.ts
  68. 3 42
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-markdown-elements.ts
  69. 4 161
      packages/editor/src/client/services/use-codemirror-editor/utils/insert-prefix.ts
  70. 15 8
      packages/editor/src/client/stores/use-editor-settings.ts
  71. 45 20
      packages/editor/src/client/stores/use-editor-shortcuts.ts
  72. 1 1
      packages/logger/package.json
  73. 1 1
      packages/pdf-converter-client/package.json
  74. 2 2
      packages/presentation/src/client/components/GrowiSlides.tsx
  75. 1 1
      packages/remark-attachment-refs/package.json
  76. 1 1
      packages/remark-lsx/package.json
  77. 1 1
      packages/slack/package.json
  78. 44 40
      pnpm-lock.yaml

+ 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_

+ 598 - 0
.kiro/specs/news-inappnotification/design.md

@@ -0,0 +1,598 @@
+# Design Document: news-inappnotification
+
+## Overview
+
+本機能は GROWI インスタンスが外部の静的 JSON フィード(GitHub Pages)を定期取得し、ニュースとして InAppNotification パネルに表示する。既存の通知(InAppNotification)とニュース(NewsItem)は別モデルで管理し、UI のみクライアント側で時系列マージして統合表示する。
+
+**Purpose**: GROWI 運営者が配信するニュース(リリース情報、セキュリティ通知、お知らせ等)を、ユーザーが既存の通知導線から確認できるようにする。
+
+**Users**: すべての GROWI ログインユーザー。ロール(admin/general)により表示対象を制御できる。
+
+**Impact**: InAppNotification サイドバーパネルに「すべて/通知/お知らせ」フィルタタブと無限スクロールを追加する。既存の「未読のみ」トグルは維持し、フィルタタブとの2重フィルタリングを提供する。
+
+### Goals
+
+- 外部フィード(`NEWS_FEED_URL`)を cron で定期取得し、MongoDB にキャッシュする
+- InAppNotification パネルで通知とニュースを統合表示する
+- ニュースの既読/未読状態をユーザー単位で管理する
+- ロール別表示制御(admin/general)をサーバーサイドで強制する
+- 多言語ニュース(`ja_JP`, `en_US` 等)をブラウザ言語に応じて表示する
+
+### Non-Goals
+
+- GROWI 管理者によるニュース作成・編集 UI(フィードリポジトリで管理)
+- リアルタイムプッシュ通知(cron ポーリングのみ)
+- `growiVersionRegExps` 以外の条件によるフィルタ(将来フェーズ)
+- RSS/Atom フォーマットへの対応(将来フェーズ)
+
+---
+
+## Architecture
+
+### Existing Architecture Analysis
+
+InAppNotification は per-user ドキュメント設計であり、`user` フィールドが必須。通知発生時に全対象ユーザー分のドキュメントを生成する(push 型)。ニュースは全ユーザーで1件のドキュメントを共有し、ユーザーがパネルを開いたときに取得する(pull 型)。この設計上の差異により、ニュースは別モデルとして実装する(詳細は `research.md` の Design Decisions を参照)。
+
+サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が `useState` でトグル state を管理し、`InAppNotificationSubstance.tsx` へ prop として渡すパターンを採用している。本機能のフィルタ state も同じパターンで実装する。
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+  GitHubPages[GitHub Pages\nfeed.json]
+  NewsCron[NewsCronService]
+  NewsItemModel[NewsItem Model]
+  NewsReadModel[NewsReadStatus Model]
+  NewsService[NewsService]
+  NewsAPI[News API\napiv3/news]
+  SidebarPanel[InAppNotification Panel\nSidebar/InAppNotification/]
+  NewsHooks[useSWRINFxNews\nstores/news.ts]
+  IANHooks[useSWRINFxInAppNotifications\nstores/in-app-notification.ts]
+  InfScroll[InfiniteScroll Component]
+  BadgeItem[PrimaryItemForNotification]
+
+  GitHubPages -->|HTTP GET cron| NewsCron
+  NewsCron -->|upsert / delete| NewsItemModel
+  NewsAPI -->|delegates| NewsService
+  NewsService -->|query| NewsItemModel
+  NewsService -->|query / write| NewsReadModel
+  SidebarPanel -->|fetch| NewsHooks
+  SidebarPanel -->|fetch| IANHooks
+  NewsHooks -->|apiv3Get| NewsAPI
+  SidebarPanel -->|renders| InfScroll
+  BadgeItem -->|count sum| NewsHooks
+```
+
+**Architecture Integration**:
+- 選択パターン: Pull 型 + クライアントサイドマージ
+- 新規コンポーネント: `NewsCronService`, `NewsItem Model`, `NewsReadStatus Model`, `NewsService`, `News API`, `NewsItem Component`, `useSWRINFxNews`
+- 既存コンポーネント拡張: `InAppNotification.tsx`(フィルタ state 追加), `InAppNotificationSubstance.tsx`(フィルタタブ + InfiniteScroll), `useSWRINFxInAppNotifications`(新設), `PrimaryItemForNotification`(未読カウント合算)
+- 既存 `InfiniteScroll.tsx` をそのまま再利用
+
+### Technology Stack
+
+| Layer | 選択 / バージョン | 役割 |
+|---|---|---|
+| Backend Cron | node-cron(既存) | フィード定期取得スケジューリング |
+| Backend HTTP | node `fetch` / axios(既存) | `NEWS_FEED_URL` から feed.json 取得 |
+| Data Store | MongoDB + Mongoose(既存) | NewsItem, NewsReadStatus の永続化 |
+| Frontend Data | SWR `useSWRInfinite`(既存) | ニュース・通知の無限スクロール取得 |
+| Frontend State | React `useState`(既存パターン) | フィルタタブ・未読トグルのローカル state |
+| i18n | next-i18next / `commons.json`(既存) | UI ラベルの多言語化 |
+
+---
+
+## System Flows
+
+### フィード取得フロー
+
+```mermaid
+sequenceDiagram
+  participant Cron as NewsCronService
+  participant Feed as GitHub Pages
+  participant DB as MongoDB
+
+  Cron->>Cron: getCronSchedule() = '0 1 * * *'
+  Cron->>Cron: NEWS_FEED_URL 未設定? → スキップ
+  Cron->>Feed: HTTP GET feed.json
+  alt 取得失敗
+    Cron->>Cron: ログ記録、既存 DB データ維持
+  else 取得成功
+    Cron->>Cron: growiVersionRegExps でフィルタ
+    Cron->>DB: externalId で upsert(新規/更新)
+    Cron->>DB: フィードにないアイテムを削除
+  end
+  Note over DB: TTL インデックス(90日)で自動削除
+```
+
+### パネル表示フロー
+
+```mermaid
+sequenceDiagram
+  participant User
+  participant Panel as InAppNotification Panel
+  participant NewsAPI as News API
+  participant IANAPI as InAppNotification API
+
+  User->>Panel: パネルを開く
+  Panel->>NewsAPI: useSWRINFxNews(limit, { onlyUnread, userRole })
+  Panel->>IANAPI: useSWRINFxInAppNotifications(limit, { status })
+  alt フィルタ = 'all'
+    Panel->>Panel: 両データを publishedAt/createdAt で降順マージ
+  else フィルタ = 'news'
+    Panel->>Panel: NewsItem のみ表示
+  else フィルタ = 'notifications'
+    Panel->>Panel: InAppNotification のみ表示
+  end
+  Panel->>User: レンダリング
+  User->>Panel: スクロール末端に達する
+  Panel->>NewsAPI: setSize(size + 1)(次ページ fetch)
+```
+
+### 既読フロー
+
+```mermaid
+sequenceDiagram
+  participant User
+  participant Component as NewsItem Component
+  participant API as News API
+  participant DB as MongoDB
+
+  User->>Component: クリック
+  Component->>API: POST /apiv3/news/mark-read { newsItemId }
+  API->>DB: NewsReadStatus upsert(userId + newsItemId)
+  Component->>Component: SWR mutate(ローカルキャッシュ更新)
+  Component->>User: url が存在すれば新タブで開く
+```
+
+---
+
+## Requirements Traceability
+
+| 要件 | Summary | コンポーネント | インターフェース | フロー |
+|---|---|---|---|---|
+| 1.1–1.7 | フィード定期取得 | NewsCronService | `executeJob()` | フィード取得フロー |
+| 2.1–2.4 | NewsItem モデル | NewsItem Model | MongoDB schema | フィード取得フロー |
+| 3.1–3.5 | 既読/未読管理 | NewsReadStatus Model, NewsService, News API | `POST /mark-read`, `GET /unread-count` | 既読フロー |
+| 4.1–4.2 | ロール別表示制御 | NewsService | `listForUser(userRole)` | パネル表示フロー |
+| 5.1–5.7 | UI 統合表示 | InAppNotification Panel, InAppNotificationSubstance | filter state props | パネル表示フロー |
+| 6.1–6.4 | 視覚表示 | NewsItem Component | CSS classes(`fw-bold`, `bg-primary`) | — |
+| 7.1–7.2 | 未読バッジ | PrimaryItemForNotification | `useSWRxNewsUnreadCount` | — |
+| 8.1–8.4 | 多言語対応 | NewsItem Component, locales | locale fallback logic | — |
+
+---
+
+## Components and Interfaces
+
+### サーバーサイド
+
+| コンポーネント | 層 | Intent | 要件 | 主要依存 |
+|---|---|---|---|---|
+| NewsCronService | Server / Cron | フィード定期取得・DB 同期 | 1.1–1.7 | CronService (P0), NewsService (P0) |
+| NewsItem Model | Server / Data | ニュースアイテムの永続化 | 2.1–2.4 | MongoDB (P0) |
+| NewsReadStatus Model | Server / Data | ユーザー既読状態の永続化 | 3.1–3.3 | MongoDB (P0) |
+| NewsService | Server / Domain | ニュース一覧・既読管理のビジネスロジック | 3.4–3.5, 4.1–4.2 | NewsItem Model (P0), NewsReadStatus Model (P0) |
+| News API | Server / API | HTTP エンドポイント提供 | 3.1–3.5, 4.1–4.2 | NewsService (P0) |
+
+---
+
+#### NewsCronService
+
+| Field | Detail |
+|---|---|
+| Intent | フィード URL から JSON を定期取得し NewsItem を upsert/delete する |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 |
+
+**Responsibilities & Constraints**
+- 毎日 AM 1:00 に実行(`'0 1 * * *'`)
+- `NEWS_FEED_URL` 未設定時はスキップ(エラーなし)
+- 取得失敗時は既存 DB データを維持
+- `growiVersionRegExps` の照合はここで実施(DB には合致アイテムのみ保存)
+- ランダムスリープ(0–5分)で複数インスタンスのリクエストを分散
+
+**Dependencies**
+- Inbound: node-cron — スケジュール実行(P0)
+- Outbound: NewsService — upsert/delete(P0)
+- External: `NEWS_FEED_URL` の HTTP エンドポイント — feed.json 取得(P0)
+
+**Contracts**: Batch [x]
+
+##### Batch / Job Contract
+- Trigger: `node-cron` スケジュール `'0 1 * * *'`
+- Input: `NEWS_FEED_URL` 環境変数、GROWI バージョン文字列
+- Output: MongoDB の NewsItem コレクションを最新フィードと同期
+- Idempotency: `externalId` ユニークインデックスにより冪等。再実行しても重複なし
+
+##### Service Interface
+```typescript
+class NewsCronService extends CronService {
+  getCronSchedule(): string;  // '0 1 * * *'
+  executeJob(): Promise<void>;
+}
+```
+
+**Implementation Notes**
+- Integration: `server/service/cron.ts` の `CronService` を継承。`startCron()` をアプリ起動時に呼ぶ
+- Validation: `NEWS_FEED_URL` が `https://` で始まることを確認。`growiVersionRegExps` は try-catch で個別評価し、不正 regex はスキップ
+- Risks: フィード取得タイムアウト(10秒推奨)。外部依存のため失敗を前提に設計する
+
+---
+
+#### NewsItem Model
+
+| Field | Detail |
+|---|---|
+| Intent | フィードから取得したニュースアイテムを全ユーザー共通で1件保持する |
+| Requirements | 2.1, 2.2, 2.3, 2.4 |
+
+**Contracts**: State [x]
+
+##### State Management
+```typescript
+interface INewsItem {
+  _id: Types.ObjectId;
+  externalId: string;                    // unique index
+  title: Record<string, string>;         // { ja_JP: string, en_US?: string, ... }
+  body?: Record<string, string>;
+  emoji?: string;
+  url?: string;
+  publishedAt: Date;                     // index
+  fetchedAt: Date;                       // TTL index (90 days = 7776000s)
+  conditions?: {
+    targetRoles?: string[];              // ['admin'] | ['admin', 'general'] | undefined
+  };
+}
+```
+
+**Indexes**:
+- `externalId`: unique index(重複排除)
+- `publishedAt`: index(降順ソート)
+- `fetchedAt`: TTL index(90日で自動削除)
+
+---
+
+#### NewsReadStatus Model
+
+| Field | Detail |
+|---|---|
+| Intent | ユーザーが既読にした時のみドキュメントを作成。ドキュメント不在 = 未読 |
+| Requirements | 3.1, 3.2, 3.3 |
+
+**Contracts**: State [x]
+
+##### State Management
+```typescript
+interface INewsReadStatus {
+  _id: Types.ObjectId;
+  userId: Types.ObjectId;              // compound unique index with newsItemId
+  newsItemId: Types.ObjectId;         // compound unique index with userId
+  readAt: Date;
+}
+```
+
+**Indexes**:
+- `{ userId, newsItemId }`: compound unique index(重複防止・冪等性保証)
+
+---
+
+#### NewsService
+
+| Field | Detail |
+|---|---|
+| Intent | ニュース一覧取得・既読管理のビジネスロジックを担う |
+| Requirements | 3.4, 3.5, 4.1, 4.2 |
+
+**Contracts**: Service [x]
+
+##### Service Interface
+```typescript
+interface INewsService {
+  listForUser(
+    userId: Types.ObjectId,
+    userRoles: string[],
+    options: { limit: number; offset: number; onlyUnread?: boolean }
+  ): Promise<PaginateResult<INewsItemWithReadStatus>>;
+
+  getUnreadCount(userId: Types.ObjectId, userRoles: string[]): Promise<number>;
+
+  markRead(userId: Types.ObjectId, newsItemId: Types.ObjectId): Promise<void>;
+
+  markAllRead(userId: Types.ObjectId, userRoles: string[]): Promise<void>;
+
+  upsertNewsItems(items: INewsItemInput[]): Promise<void>;
+
+  deleteNewsItemsByExternalIds(externalIds: string[]): Promise<void>;
+}
+
+interface INewsItemWithReadStatus extends INewsItem {
+  isRead: boolean;
+}
+```
+
+- Preconditions: `userId` は有効な ObjectId
+- Postconditions: `listForUser` の結果は `publishedAt` 降順。各アイテムに `isRead` が付与される
+- ロールフィルタ: `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返す
+
+---
+
+#### News API
+
+| Field | Detail |
+|---|---|
+| Intent | ニュース一覧取得・既読管理の HTTP エンドポイントを提供する |
+| Requirements | 3.1, 3.4, 3.5, 4.1, 4.2 |
+
+**Contracts**: API [x]
+
+##### API Contract
+
+| Method | Endpoint | Request | Response | Errors |
+|---|---|---|---|---|
+| GET | `/apiv3/news/list` | `?limit&offset&onlyUnread` | `PaginateResult<INewsItemWithReadStatus>` | 401 |
+| GET | `/apiv3/news/unread-count` | — | `{ count: number }` | 401 |
+| POST | `/apiv3/news/mark-read` | `{ newsItemId: string }` | `{ ok: true }` | 400, 401 |
+| POST | `/apiv3/news/mark-all-read` | — | `{ ok: true }` | 401 |
+
+全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する。
+
+**Implementation Notes**
+- Integration: `apps/app/src/server/routes/apiv3/news.ts` に新規作成
+- Validation: `newsItemId` は `mongoose.isValidObjectId()` で検証
+- Risks: ロールフィルタはサーバーサイドで強制。クライアントから `targetRoles` を受け取らない
+
+---
+
+### クライアントサイド
+
+| コンポーネント | 層 | Intent | 要件 | 主要依存 |
+|---|---|---|---|---|
+| useSWRINFxNews | Client / Hooks | ニュースアイテムの無限スクロール取得 | 5.4 | News API (P0) |
+| useSWRxNewsUnreadCount | Client / Hooks | ニュース未読カウント取得 | 7.1 | News API (P0) |
+| useSWRINFxInAppNotifications | Client / Hooks | 通知の無限スクロール取得(既存 hook を拡張) | 5.4 | InAppNotification API (P0) |
+| InAppNotification.tsx(変更) | Client / UI | フィルタ state を追加管理 | 5.2, 5.3 | useState (P0) |
+| InAppNotificationSubstance.tsx(変更) | Client / UI | フィルタタブ + InfiniteScroll | 5.1–5.5 | useSWRINFxNews (P0), InfiniteScroll (P0) |
+| NewsItem Component | Client / UI | ニュースアイテム1件の表示 | 5.5, 5.6, 5.7, 6.1–6.4, 8.1–8.2 | — |
+| PrimaryItemForNotification(変更) | Client / UI | 未読バッジに NewsItem の未読数を合算 | 7.1, 7.2 | useSWRxNewsUnreadCount (P0) |
+
+---
+
+#### useSWRINFxNews
+
+| Field | Detail |
+|---|---|
+| Intent | ニュースアイテムの無限スクロールデータ取得 |
+| Requirements | 5.4 |
+
+**Contracts**: State [x]
+
+##### State Management
+```typescript
+// stores/news.ts
+export const useSWRINFxNews = (
+  limit: number,
+  options?: { onlyUnread?: boolean },
+  config?: SWRConfiguration,
+): SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>;
+
+export const useSWRxNewsUnreadCount = (): SWRResponse<number, Error>;
+```
+
+キー: `['/news/list', limit, pageIndex, options.onlyUnread]`
+
+---
+
+#### InAppNotification.tsx(変更)
+
+| Field | Detail |
+|---|---|
+| Intent | フィルタタブ state を追加し、子コンポーネントへ伝播する |
+| Requirements | 5.2, 5.3 |
+
+**Implementation Notes**
+- 既存 `isUnopendNotificationsVisible` state はそのまま維持
+- `activeFilter: 'all' | 'news' | 'notifications'` を `useState('all')` で追加
+- `InAppNotificationForms` と `InAppNotificationContent` へ prop を追加
+
+```typescript
+type FilterType = 'all' | 'news' | 'notifications';
+```
+
+---
+
+#### InAppNotificationSubstance.tsx(変更)
+
+| Field | Detail |
+|---|---|
+| Intent | フィルタタブ UI の追加と、InfiniteScroll を用いた統合リスト表示 |
+| Requirements | 5.1, 5.2, 5.3, 5.4, 5.5 |
+
+**Contracts**: State [x]
+
+**InAppNotificationForms への追加**:
+- フィルタボタン(「すべて」「通知」「お知らせ」)を Bootstrap `btn-group` で実装
+- 既存「未読のみ」トグルは維持
+
+**InAppNotificationContent の変更**:
+- `activeFilter` に応じて3パターンに分岐
+  - `'all'`: `useSWRINFxNews` + `useSWRINFxInAppNotifications` の結果を `publishedAt/createdAt` 降順でマージ
+  - `'news'`: `useSWRINFxNews` のみ。`NewsList` に渡す
+  - `'notifications'`: `useSWRINFxInAppNotifications` のみ。既存 `InAppNotificationList` に渡す
+- 既存 `InfiniteScroll` コンポーネントを使用(`client/components/InfiniteScroll.tsx`)
+- 既存 `// TODO: Infinite scroll implemented` コメントを解消
+
+---
+
+#### NewsItem Component
+
+| Field | Detail |
+|---|---|
+| Intent | ニュースアイテム1件を表示する(emoji、タイトル、未読インジケータ) |
+| Requirements | 5.5, 5.6, 5.7, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2 |
+
+**Implementation Notes**
+- 配置: `features/news/client/components/NewsItem.tsx`
+- ロケールフォールバック: `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
+- 未読: `fw-bold` + 左端に `bg-primary` 8px 丸ドット
+- 既読: `fw-normal` + 同幅の透明スペーサー
+- `emoji` 未設定時は `📢` をフォールバック
+- クリック時: `POST /mark-read` + SWR mutate + `url` があれば新タブで開く
+
+---
+
+## Data Models
+
+### Domain Model
+
+```mermaid
+erDiagram
+  NewsItem {
+    ObjectId _id
+    string externalId
+    object title
+    object body
+    string emoji
+    string url
+    Date publishedAt
+    Date fetchedAt
+    object conditions
+  }
+  NewsReadStatus {
+    ObjectId _id
+    ObjectId userId
+    ObjectId newsItemId
+    Date readAt
+  }
+  User {
+    ObjectId _id
+    string username
+    string role
+  }
+
+  NewsReadStatus }o--|| User : "userId"
+  NewsReadStatus }o--|| NewsItem : "newsItemId"
+```
+
+- NewsItem は全ユーザーで共有する集約ルート(per-instance、not per-user)
+- NewsReadStatus は「ユーザーが既読にした」という事実のみを記録。削除によって「未読に戻す」ことも可能
+
+### Physical Data Model
+
+**NewsItem Collection** (`newsitems`):
+
+```typescript
+const NewsItemSchema = new Schema<INewsItem>({
+  externalId: { type: String, required: true, unique: true },
+  title: { type: Map, of: String, required: true },
+  body: { type: Map, of: String },
+  emoji: { type: String },
+  url: { type: String },
+  publishedAt: { type: Date, required: true, index: true },
+  fetchedAt: { type: Date, required: true, index: { expires: '90d' } },
+  conditions: {
+    targetRoles: [{ type: String }],
+  },
+});
+```
+
+**NewsReadStatus Collection** (`newsreadstatuses`):
+
+```typescript
+const NewsReadStatusSchema = new Schema<INewsReadStatus>({
+  userId: { type: Schema.Types.ObjectId, required: true, ref: 'User' },
+  newsItemId: { type: Schema.Types.ObjectId, required: true, ref: 'NewsItem' },
+  readAt: { type: Date, required: true, default: Date.now },
+});
+NewsReadStatusSchema.index({ userId: 1, newsItemId: 1 }, { unique: true });
+```
+
+### Data Contracts & Integration
+
+**API レスポンス型**:
+
+```typescript
+interface INewsItemWithReadStatus {
+  _id: string;
+  externalId: string;
+  title: Record<string, string>;
+  body?: Record<string, string>;
+  emoji?: string;
+  url?: string;
+  publishedAt: string;  // ISO 8601
+  conditions?: { targetRoles?: string[] };
+  isRead: boolean;
+}
+
+interface PaginateResult<T> {
+  docs: T[];
+  totalDocs: number;
+  limit: number;
+  offset: number;
+  hasNextPage: boolean;
+}
+```
+
+---
+
+## Error Handling
+
+### Error Strategy
+
+フィード取得はフォールバック優先(失敗しても既存データを維持)。API エンドポイントは fail-fast(認証エラーは即時 401)。
+
+### Error Categories and Responses
+
+| カテゴリ | エラー | 対応 |
+|---|---|---|
+| Cron / External | フィード取得失敗(ネットワーク、タイムアウト) | `logger.error` + 既存 DB データ維持。次回 cron で再試行 |
+| Cron / Config | `NEWS_FEED_URL` 未設定 | スキップ(ログなし)。設定されるまで無害に動作 |
+| Cron / Validation | `growiVersionRegExps` に不正 regex | try-catch で該当アイテムをスキップ、`logger.warn` |
+| API / Auth | 未認証リクエスト | 401(`loginRequiredStrictly` が処理) |
+| API / Validation | 不正な `newsItemId` フォーマット | 400(`mongoose.isValidObjectId()` チェック) |
+| API / Conflict | `mark-read` の重複呼び出し | upsert で冪等処理。エラーなし |
+
+### Monitoring
+
+- `NewsCronService.executeJob()` の成功/失敗を `logger.info` / `logger.error` で記録
+- `mark-read` 件数を `logger.debug` で記録(デバッグ用)
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+
+- `NewsCronService.executeJob()`: 正常取得 → upsert、取得失敗 → DB 変更なし、`NEWS_FEED_URL` 未設定 → スキップ
+- `NewsCronService.executeJob()`: `growiVersionRegExps` 一致 → 保存、不一致 → 除外
+- `NewsService.listForUser()`: `targetRoles` フィルタ(admin のみ、general 除外)
+- `NewsService.listForUser()`: `onlyUnread=true` で未読のみ返す
+- `NewsService.getUnreadCount()`: 未読件数の正確な計算
+
+### Integration Tests
+
+- `GET /apiv3/news/list`: ロール別フィルタが正しく動作する
+- `POST /apiv3/news/mark-read`: 2回呼んでもエラーなし(冪等性)
+- `POST /apiv3/news/mark-all-read` 後に `GET /apiv3/news/unread-count` が 0 を返す
+- 未認証リクエストが 401 を返す
+
+### Component Tests
+
+- `NewsItem`: `emoji` 未設定時に 📢 が表示される
+- `NewsItem`: `title` ロケールフォールバック(`browserLocale → ja_JP → en_US`)
+- `NewsItem`: 未読時に `fw-bold` + 青ドット、既読時に `fw-normal` + スペーサー
+- `InAppNotificationForms`: フィルタタブのクリックで `activeFilter` が変わる
+
+---
+
+## Security Considerations
+
+- すべての `/apiv3/news/*` エンドポイントに `loginRequiredStrictly` を適用する
+- `conditions.targetRoles` のフィルタリングはサーバーサイドの `NewsService.listForUser()` で強制する。クライアントから `targetRoles` パラメータを受け付けない
+- `NEWS_FEED_URL` は `https://` のみ許可(HTTP 不可)
+- フィードから取得したデータはそのまま DB に保存し、クライアントへのレスポンス時に Mongoose スキーマで型安全に扱う
+
+## Performance & Scalability
+
+- NewsItem は全ユーザーで1件共有のため、ユーザー数に比例してドキュメントが増えない
+- `publishedAt` インデックスにより降順ソートが効率的
+- `fetchedAt` TTL インデックス(90日)で古いデータを自動削除し、コレクションサイズを制限
+- `NewsReadStatus` の compound unique index により `listForUser` の LEFT JOIN 相当クエリが効率的

+ 1 - 1
.kiro/specs/news-inappnotification/requirements.md

@@ -72,7 +72,7 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 2. The InAppNotificationパネル shall 上部にフィルタボタン(「すべて」「通知」「お知らせ」)を配置し、デフォルトは「すべて」とする。「お知らせ」選択時はニュースのみ、「通知」選択時はニュース以外のすべての通知を表示する
 3. The InAppNotificationパネル shall 既存の「未読のみ」トグルスイッチを維持し、種別フィルタと組み合わせた2重フィルタリングを提供する。種別フィルタ(すべて/通知/お知らせ)で表示対象を絞り込んだ上で、トグルON時は未読アイテムのみをさらに絞り込む
 4. The InAppNotificationパネル shall リスト領域に最大高さを設定し、超過分はスクロールで表示する。スクロールが末端に達した場合は次のページを自動で読み込む無限スクロールとする
-5. The InAppNotificationパネル shall ニュースアイテムの `type` に応じた絵文字アイコンをタイトル前に表示する(`release`→🎉, `security`→⚠️, `tips`→💡, `maintenance`→🔧, `announcement`→📢, 未設定→📢)
+5. The InAppNotificationパネル shall ニュースアイテムの `emoji` フィールドをタイトル前に表示する。`emoji` 未設定の場合は 📢 をフォールバックとして使用する
 6. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall ニュースの詳細 URL を新しいタブで開く
 7. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall 該当ニュースを既読としてマークし、未読インジケータを更新する
 

+ 142 - 0
.kiro/specs/news-inappnotification/research.md

@@ -0,0 +1,142 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Discovery findings and architectural rationale for the news-inappnotification feature.
+
+---
+
+## Summary
+
+- **Feature**: `news-inappnotification`
+- **Discovery Scope**: Complex Integration(新機能 + 既存 InAppNotification UI 拡張)
+- **Key Findings**:
+  - `CronService` 抽象クラスが `server/service/cron.ts` に存在。`NewsCronService extends CronService` のみで cron 基盤が利用可能
+  - `InfiniteScroll` コンポーネントが `client/components/InfiniteScroll.tsx` に存在。`SWRInfiniteResponse` を受け取る汎用実装で再利用可能
+  - サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が state を管理。フィルタ追加はここへの `useState` 追加で対応できる
+  - マージドビュー(すべて)はサーバーサイド JOIN 不要。クライアントサイドで日時ソートするだけで実現できる
+  - 既存 `useSWRxInAppNotifications` は `useSWR`(ページネーション)ベース。無限スクロールのために `useSWRInfinite` 版(`useSWRINFx` prefix)を新設する必要がある
+
+---
+
+## Research Log
+
+### InAppNotification 既存実装の分析
+
+- **Context**: NewsItem を既存 InAppNotification に乗せるか、別モデルにするかの判断
+- **Sources**: `server/models/in-app-notification.ts`, `server/routes/apiv3/in-app-notification.ts`, `server/service/in-app-notification.ts`
+- **Findings**:
+  - InAppNotification は per-user ドキュメント設計。`user` フィールドが必須で、配信時点で全ユーザー分のドキュメントを生成する
+  - `status` フィールド(UNOPENED/OPENED)は per-user ドキュメントが存在することを前提としており、配信時点でのドキュメント生成が不可避
+  - `targetModel` と `action` が enum 制約を持ち、ニュースの externalId 管理に使えない
+  - `snapshot` フィールドにニュース本文を格納した場合、ユーザー数分の本文コピーが発生する
+- **Implications**: NewsItem は別モデルとして実装する。requirements.md の Note に記載された設計根拠が技術的に正確であることを確認
+
+### CronService パターンの確認
+
+- **Context**: フィード定期取得の実装方針
+- **Sources**: `server/service/cron.ts`, `server/service/access-token/access-token-deletion-cron.ts`
+- **Findings**:
+  - `abstract getCronSchedule(): string` と `abstract executeJob(): Promise<void>` を実装するだけでよい
+  - `node-cron` を使用。スケジュール変更は `getCronSchedule()` のオーバーライドで対応
+  - `startCron()` を呼ぶだけで cron が開始される
+- **Implications**: `NewsCronService` の実装は最小限で済む
+
+### InfiniteScroll 実装パターン
+
+- **Context**: 要件 5.4「無限スクロール」の実装方針
+- **Sources**: `client/components/InfiniteScroll.tsx`, `stores/page-listing.tsx`
+- **Findings**:
+  - `InfiniteScroll` コンポーネントは `SWRInfiniteResponse` を props で受け取る汎用コンポーネント
+  - `IntersectionObserver` でセンチネル要素を監視し、`setSize(size + 1)` でページ追加
+  - `useSWRInfinite` のキー命名規則: `useSWRINFx*` prefix
+  - `InAppNotificationSubstance.tsx` に `// TODO: Infinite scroll implemented` コメントあり。今回の実装でこの TODO を解消する
+- **Implications**: `useSWRINFxNews` と `useSWRINFxInAppNotifications` を新設し、既存の `InfiniteScroll` コンポーネントをそのまま利用する
+
+### フロントエンド状態管理パターン
+
+- **Context**: フィルタタブ(すべて/通知/お知らせ)と未読トグルの状態管理方針
+- **Sources**: `Sidebar/InAppNotification/InAppNotification.tsx`, Jotai atom パターン
+- **Findings**:
+  - 既存の「未読のみ」トグルは `useState` で管理され、prop として子コンポーネントに渡している
+  - Jotai は cross-component の持続的 state に使用。パネル内のローカル UI state には `useState` で十分
+  - フィルタタブは同様に `useState` で `'all' | 'news' | 'notifications'` を管理する
+- **Implications**: Jotai は不要。`useState` で統一する
+
+### クライアントサイドマージの実現可能性
+
+- **Context**: 「すべて」フィルタで通知とニュースを時系列マージする実装
+- **Findings**:
+  - InAppNotification は `createdAt` 順、NewsItem は `publishedAt` 順
+  - 両者を `useSWRInfinite` で別々に取得し、各ページのデータをマージしてソート
+  - ページング境界をまたぐマージは複雑になるため、「すべて」フィルタ時は両 API を large limit(例: 20件)で fetch し、クライアントマージする方針
+- **Implications**: 無限スクロールのマージは実装複雑度が高い。「すべて」フィルタ時は両データソースを独立した `useSWRInfinite` で管理し、表示時にマージする
+
+### i18n キー管理
+
+- **Context**: 新規 UI ラベルの多言語化
+- **Sources**: `public/static/locales/ja_JP/commons.json`
+- **Findings**:
+  - `in_app_notification` 名前空間に既存キーが存在(`only_unread`, `no_notification` 等)
+  - 対応ロケール: `ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR`
+- **Implications**: 同名前空間に追加キー(`news`, `all`, `notifications`, `no_news`)を追加する
+
+---
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks | Notes |
+|---|---|---|---|---|
+| サーバーサイドマージ | DB の aggregate で通知+ニュースを JOIN してソート | クライアントが単純 | 異なるモデルの JOIN は複雑、ページング境界の処理が難しい | 採用しない |
+| **クライアントサイドマージ** | 別 API で取得しクライアントで日時ソート | 各 API が独立してシンプル | 「すべて」時は2回 API コール | **採用** |
+| ニュース専用ページ | `/me/news` 等の別ページにニュースを表示 | 実装シンプル | 導線が分散、要件 5.1 に不合致 | 採用しない |
+
+---
+
+## Design Decisions
+
+### Decision: NewsItem と NewsReadStatus を別モデルとする
+
+- **Context**: InAppNotification モデルで代替できないか検討
+- **Alternatives Considered**:
+  1. InAppNotification モデルを拡張して newsItem を追加
+  2. 新規 NewsItem + NewsReadStatus モデルを作成
+- **Selected Approach**: 新規モデルを作成(Option 2)
+- **Rationale**: InAppNotification は per-user ドキュメント設計。配信時に全ユーザー分のドキュメントを生成する必要があり、SaaS 規模でストレージ効率が悪い。NewsItem は全ユーザーで1件を共有し、NewsReadStatus は既読時のみ作成する
+- **Trade-offs**: 新モデル追加のコストはあるが、スケール時のストレージ効率は大幅に向上する
+- **Follow-up**: TTL インデックス(90日)の動作確認
+
+### Decision: growiVersionRegExps のフィルタは cron 側で適用
+
+- **Context**: バージョン条件のフィルタタイミング
+- **Alternatives Considered**:
+  1. DB に全件保存し、API クエリ時にフィルタ
+  2. cron 取得時にフィルタし、該当アイテムのみ保存
+- **Selected Approach**: cron 取得時にフィルタ(Option 2)
+- **Rationale**: GROWI のバージョンはインスタンス起動時に確定し、動的に変わらない。DB に不要なデータを保存しない方がクリーン
+- **Trade-offs**: バージョンアップ後に古いアイテムが再表示されない(次回 cron まで)。許容範囲内
+
+### Decision: useSWRInfinite で InAppNotification も再実装
+
+- **Context**: 既存 `useSWRxInAppNotifications` は `useSWR` ベース(ページネーション)
+- **Alternatives Considered**:
+  1. 既存 hook をそのまま使い、InAppNotification の無限スクロールは別途実装
+  2. `useSWRInfinite` ベースの新 hook に切り替え
+- **Selected Approach**: `useSWRINFxInAppNotifications` を新設(Option 2)
+- **Rationale**: `InfiniteScroll` コンポーネントは `SWRInfiniteResponse` を要求する。既存 TODO コメントも無限スクロール実装を示唆している
+- **Trade-offs**: 既存 `useSWRxInAppNotifications` は `InAppNotificationPage.tsx` でも使われているため、両方を維持する
+
+---
+
+## Risks & Mitigations
+
+- クライアントサイドマージで「すべて」フィルタ時に2倍の API コール — 初回は許容。将来的にサーバーサイド集約 API を検討
+- フィード URL が HTTPS でない場合のセキュリティリスク — `NEWS_FEED_URL` のバリデーションで `https://` を強制
+- `growiVersionRegExps` の regex が不正な場合 — try-catch でキャッチし、そのアイテムをスキップしてログ記録
+
+---
+
+## References
+
+- [node-cron documentation](https://github.com/node-cron/node-cron) — cron スケジュール構文
+- [SWR Infinite Loading](https://swr.vercel.app/docs/pagination#infinite-loading) — `useSWRInfinite` パターン
+- [Mongoose TTL indexes](https://mongoosejs.com/docs/guide.html#indexes) — TTL インデックス設定

+ 5 - 5
.kiro/specs/news-inappnotification/spec.json

@@ -3,18 +3,18 @@
   "created_at": "2026-03-24T00:00:00Z",
   "updated_at": "2026-03-24T01:00:00Z",
   "language": "ja",
-  "phase": "requirements-generated",
+  "phase": "tasks-generated",
   "approvals": {
     "requirements": {
       "generated": true,
-      "approved": false
+      "approved": true
     },
     "design": {
-      "generated": false,
-      "approved": false
+      "generated": true,
+      "approved": true
     },
     "tasks": {
-      "generated": false,
+      "generated": true,
       "approved": false
     }
   },

+ 150 - 0
.kiro/specs/news-inappnotification/tasks.md

@@ -0,0 +1,150 @@
+# Implementation Plan
+
+- [ ] 0. 動作確認用ローカルフィードサーバーをセットアップする
+  - `/tmp/feed.json` にサンプルフィードファイルを作成する。`emoji` あり・なし(未設定時は 📢 フォールバック確認)、`title`/`body` の多言語フィールド(`ja_JP`, `en_US`)、`url` あり・なし、`conditions.targetRoles`(admin のみ、全ユーザー)の両パターンを含む複数アイテムで構成する
+  - devcontainer 内で `cd /tmp && python3 -m http.server 8099` を起動し、`http://localhost:8099/feed.json` でアクセスできることを確認する
+  - `.env` に `NEWS_FEED_URL=http://localhost:8099/feed.json` を追加する
+  - 以降のタスクで cron 動作確認が必要な場合はこのサーバーを使用する
+  - _Requirements: 1.1, 1.6_
+
+- [ ] 1. データモデルを実装する
+- [ ] 1.1 (P) NewsItem モデルを実装する
+  - `externalId`(ユニークインデックス)、多言語 `title`/`body`(Map of String)、`emoji`、`url`、`publishedAt`(インデックス)、`fetchedAt`(TTL 90日インデックス)、`conditions.targetRoles` を持つ Mongoose スキーマを定義する
+  - 型インターフェース `INewsItem` と `INewsItemHasId` を定義する
+  - _Requirements: 2.1, 2.2, 2.3, 2.4_
+
+- [ ] 1.2 (P) NewsReadStatus モデルを実装する
+  - `userId`・`newsItemId` の複合ユニークインデックス、`readAt` を持つ Mongoose スキーマを定義する
+  - 型インターフェース `INewsReadStatus` を定義する
+  - _Requirements: 3.3_
+
+- [ ] 2. ニュースサービス層を実装する
+- [ ] 2.1 ニュース一覧取得ロジックを実装する
+  - `listForUser(userId, userRoles, { limit, offset, onlyUnread })` を実装する
+  - `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返すロール別フィルタを適用する
+  - NewsReadStatus との突き合わせにより各アイテムに `isRead: boolean` を付与する
+  - 結果は `publishedAt` 降順で返す
+  - _Requirements: 3.4, 4.1, 4.2_
+
+- [ ] 2.2 既読管理ロジックを実装する
+  - `markRead(userId, newsItemId)` を実装する。NewsReadStatus を upsert することで冪等性を保証する
+  - `markAllRead(userId, userRoles)` を実装する。ロール別フィルタに合致する全未読アイテムを一括既読にする
+  - `getUnreadCount(userId, userRoles)` を実装する
+  - _Requirements: 3.1, 3.2, 3.5_
+
+- [ ] 2.3 フィード同期ロジックを実装する
+  - `upsertNewsItems(items)` を実装する。`externalId` をキーに upsert し、`fetchedAt` を更新する
+  - `deleteNewsItemsByExternalIds(externalIds)` を実装する
+  - _Requirements: 1.2, 1.3_
+
+- [ ] 3. News API エンドポイントを実装する
+- [ ] 3.1 (P) ニュース取得エンドポイントを実装する
+  - `GET /apiv3/news/list`(`limit`, `offset`, `onlyUnread` クエリパラメータ)を実装する
+  - `GET /apiv3/news/unread-count` を実装する
+  - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
+  - _Requirements: 3.4, 3.5, 4.1, 4.2_
+
+- [ ] 3.2 (P) ニュース既読操作エンドポイントを実装する
+  - `POST /apiv3/news/mark-read`(`newsItemId` を受け取る)を実装する。`newsItemId` を `mongoose.isValidObjectId()` で検証する
+  - `POST /apiv3/news/mark-all-read` を実装する
+  - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
+  - _Requirements: 3.1, 3.2_
+
+- [ ] 3.3 News API ルートをアプリに登録する
+  - Express アプリの apiv3 ルーター定義に `news.ts` を追加する
+  - _Requirements: 3.1, 3.4_
+
+- [ ] 4. NewsCronService を実装する
+- [ ] 4.1 (P) フィード取得・DB 同期処理を実装する
+  - `CronService` を継承し `getCronSchedule()` で `'0 1 * * *'` を返す
+  - `executeJob()` を実装する:`NEWS_FEED_URL` 未設定時はスキップ、HTTP GET、取得失敗時はログ記録のみ(既存データ維持)
+  - 取得した各アイテムの `growiVersionRegExps` と現バージョンを照合し、不一致アイテムを除外する。不正 regex は try-catch でスキップしてログ警告する
+  - フィード外のアイテムを DB から削除し、ランダムスリープ(0–5分)でリクエストを分散する
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_
+
+- [ ] 4.2 cron をアプリ起動時に登録する
+  - アプリの初期化処理で `NewsCronService.startCron()` を呼ぶ
+  - _Requirements: 1.1_
+
+- [ ] 5. フロントエンド SWR フックを実装する
+- [ ] 5.1 (P) ニュース用 SWR フックを新設する
+  - `useSWRINFxNews(limit, options)` を `useSWRInfinite` ベースで実装する。キーに `limit`, `pageIndex`, `onlyUnread` を含める
+  - `useSWRxNewsUnreadCount()` を実装する
+  - _Requirements: 5.4, 7.1_
+
+- [ ] 5.2 (P) InAppNotification 用の無限スクロール対応フックを追加する
+  - 既存 `useSWRxInAppNotifications`(`useSWR` ベース)に加えて `useSWRINFxInAppNotifications(limit, options)` を `useSWRInfinite` ベースで新設する
+  - 既存フックは `InAppNotificationPage.tsx` での利用のため維持する
+  - _Requirements: 5.4_
+
+- [ ] 6. InAppNotification パネルを改修する
+- [ ] 6.1 フィルタタブを追加する
+  - `InAppNotification.tsx` に `activeFilter: 'all' | 'news' | 'notifications'` の state(デフォルト `'all'`)を追加し、`InAppNotificationForms` と `InAppNotificationContent` へ prop として渡す
+  - `InAppNotificationForms` に Bootstrap `btn-group` でフィルタボタン(「すべて」「通知」「お知らせ」)を追加する。既存「未読のみ」トグルは維持する
+  - _Requirements: 5.2, 5.3_
+
+- [ ] 6.2 無限スクロールを導入する
+  - `InAppNotificationContent` で `useSWRINFxNews` と `useSWRINFxInAppNotifications` を使用するよう変更する
+  - 既存の `InfiniteScroll` コンポーネントをラップしてリストを表示する
+  - 既存の `// TODO: Infinite scroll implemented` コメントを解消する
+  - _Requirements: 5.4_
+
+- [ ] 6.3 「すべて」フィルタ時のクライアントサイドマージを実装する
+  - `activeFilter === 'all'` の場合、通知(`createdAt`)とニュース(`publishedAt`)を日時降順でマージして表示する
+  - `activeFilter === 'news'` の場合は NewsItem のみ、`activeFilter === 'notifications'` の場合は InAppNotification のみ表示する
+  - _Requirements: 5.1, 5.2_
+
+- [ ] 7. NewsItem コンポーネントを実装する
+- [ ] 7.1 (P) ニュースアイテムの表示コンポーネントを実装する
+  - `emoji` フィールドをタイトル前に表示する。未設定時は 📢 をフォールバックとする
+  - 多言語タイトルをブラウザ言語で解決する。フォールバック順は `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
+  - 未読時はタイトルを `fw-bold` + 左端に `bg-primary` 8px 丸ドット、既読時は `fw-normal` + 同幅の透明スペーサーで表示する
+  - _Requirements: 5.5, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2_
+
+- [ ] 7.2 (P) ニュースアイテムのクリック処理を実装する
+  - クリック時に `POST /apiv3/news/mark-read` を呼び、SWR キャッシュを mutate して未読インジケータを更新する
+  - `url` が設定されている場合は新しいタブで開く
+  - _Requirements: 5.6, 5.7_
+
+- [ ] 8. (P) 未読バッジにニュース未読数を合算する
+  - `PrimaryItemForNotification` で `useSWRxNewsUnreadCount` を呼び、既存の InAppNotification 未読カウントと合算してバッジに表示する
+  - 全ニュースが既読の場合はニュース分のカウントを含めない
+  - _Requirements: 7.1, 7.2_
+
+- [ ] 9. (P) i18n ロケールファイルを更新する
+  - `commons.json` の `in_app_notification` 名前空間に以下のキーを全ロケール(`ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR`)に追加する:`news`(お知らせ)、`notifications`(通知)、`all`(すべて)、`no_news`(ニュースはありません)
+  - _Requirements: 8.3, 8.4_
+
+- [ ] 10. サーバーサイドテストを実装する
+- [ ] 10.1 NewsCronService のテストを実装する
+  - `executeJob()` が正常取得時に upsert・削除を行うことを確認する
+  - `NEWS_FEED_URL` 未設定時にスキップすることを確認する
+  - フィード取得失敗時に DB データが変更されないことを確認する
+  - `growiVersionRegExps` の一致・不一致・不正 regex の各ケースをテストする
+  - _Requirements: 1.1, 1.2, 1.3, 1.5, 1.6, 1.7_
+
+- [ ] 10.2 NewsService のテストを実装する
+  - `listForUser()` がロール別フィルタを正しく適用し `isRead` を付与することを確認する
+  - `onlyUnread=true` で未読のみ返ることを確認する
+  - `markRead()` の冪等性(2回呼んでもエラーなし)を確認する
+  - `getUnreadCount()` が `markAllRead()` 後に 0 を返すことを確認する
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1, 4.2_
+
+- [ ] 10.3 News API 統合テストを実装する
+  - `GET /apiv3/news/list` がロール別フィルタを強制することを確認する
+  - `POST /apiv3/news/mark-read` が冪等であることを確認する
+  - 未認証リクエストが 401 を返すことを確認する
+  - _Requirements: 3.1, 3.4, 4.1_
+
+- [ ] 11. フロントエンドテストを実装する
+- [ ] 11.1 NewsItem コンポーネントのテストを実装する
+  - `emoji` 未設定時に 📢 が表示されることをテストする
+  - タイトルのロケールフォールバック(`browserLocale → ja_JP → en_US`)をテストする
+  - 未読・既読の視覚表示(`fw-bold`、青ドット、スペーサー)をテストする
+  - クリック時に `mark-read` が呼ばれ、`url` がある場合に新タブで開くことをテストする
+  - _Requirements: 5.5, 5.6, 5.7, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2_
+
+- [ ]* 11.2 InAppNotification パネルのフィルタ動作をテストする
+  - フィルタタブ切り替えで表示対象が変わることを確認する(5.2 の AC カバレッジ)
+  - 「未読のみ」トグルとの組み合わせで2重フィルタリングが機能することを確認する(5.3 の AC カバレッジ)
+  - _Requirements: 5.2, 5.3_

+ 0 - 14
.kiro/specs/suggest-path/design.md

@@ -343,17 +343,3 @@ interface SuggestPathResponse {
 
 Each component fails independently. Memo is always generated first as guaranteed fallback.
 
-## Security Considerations
-
-- **Authentication**: All requests require valid API token or login session (standard middleware)
-- **Authorization**: Search results are permission-scoped via `searchKeyword()` user/group parameters
-- **Input safety**: Content body is passed to GROWI AI, not directly to Elasticsearch — no NoSQL injection risk
-- **AI prompt injection**: System prompt and user content are separated to minimize prompt injection risk
-- **Information leakage**: Error responses use generic messages (Req 9.2)
-
-## Performance Considerations
-
-- Content analysis and candidate evaluation are sequential (ES sits between) — 2 AI roundtrips minimum
-- Search-evaluate pipeline and category generation run in parallel to minimize total latency
-- ES snippets (not full page bodies) are passed to AI to manage context budget
-- Score threshold filtering reduces the number of candidates passed to the 2nd AI call

+ 1 - 1
.kiro/specs/suggest-path/requirements.md

@@ -36,7 +36,7 @@ Phase 2 was revised based on reviewer feedback: (1) flow/stock information class
 
 **Summary**: Extracts top-level path segment from keyword-matched pages as a `category` type suggestion. Includes parent grant. Omitted if no match found.
 
-> **Note**: May overlap with the AI-based evaluation approach (Reqs 11, 12). Whether to retain, merge, or remove will be determined after reviewer discussion.
+> **Note**: After reviewer discussion, the prior implementation was retained as-is. Potential overlap with the AI-based evaluation approach (Reqs 11, 12) was acknowledged; merging or removal deferred to a future iteration.
 
 ### Requirement 5: Content Analysis via GROWI AI (Phase 2)
 

+ 1 - 1
.kiro/specs/suggest-path/spec.json

@@ -1,7 +1,7 @@
 {
   "feature_name": "suggest-path",
   "created_at": "2026-02-10T12:00:00Z",
-  "updated_at": "2026-03-23T00:00:00Z",
+  "updated_at": "2026-04-15T00:00:00Z",
   "language": "en",
   "phase": "implementation-complete",
   "approvals": {

+ 0 - 2
.kiro/specs/suggest-path/tasks.md

@@ -20,8 +20,6 @@
 
 ## Post-Implementation Refactoring (from code review)
 
-See `gap-analysis.md` for detailed rationale.
-
 - [x] 8. Simplify service layer abstractions
 - [x] 8.1 Remove `GenerateSuggestionsDeps` DI pattern from `generate-suggestions.ts`
 - [x] 8.2 Remove `RetrieveSearchCandidatesOptions` from `retrieve-search-candidates.ts`

+ 2 - 0
apps/app/docker/Dockerfile.dockerignore

@@ -37,6 +37,8 @@ apps/slackbot-proxy
 # Documentation (no .md files are needed for build)
 # ============================================================
 **/*.md
+# Keep locale template .md files required at runtime by the installer
+!apps/app/resource/locales/**/*.md
 
 # ============================================================
 # Local environment overrides

+ 1 - 1
apps/app/package.json

@@ -121,7 +121,7 @@
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
-    "axios": "^1.11.0",
+    "axios": "^1.15.0",
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",

+ 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();
+    });
+  });

+ 9 - 2
apps/app/resource/Contributor.js

@@ -18,6 +18,7 @@ const contributors = [
           { position: 'Haberion', name: 'hakumizuki' },
           { position: 'Undefined', name: 'miya' },
           { position: 'Hoimi Slime', name: 'satof3' },
+          { position: 'Archer', name: 'Ryosei-Fukushima' },
         ],
       },
       {
@@ -172,9 +173,15 @@ const contributors = [
         additionalClass: 'col-md-4 my-4',
         members: [
           { name: 'Crowi Team' },
-          { position: 'Ambassador', name: 'Tsuyoshi Suzuki' },
+          { name: 'RIKEN' },
           { name: 'JPCERT/CC' },
-          { name: 'goofmint' },
+        ],
+      },
+      {
+        additionalClass: 'col-md-6 my-4',
+        members: [
+          { position: 'Ambassador', name: 'Tsuyoshi Suzuki' },
+          { position: 'Ambassador', name: 'goofmint' },
         ],
       },
       {

+ 4 - 3
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -1,9 +1,10 @@
-import React, { useEffect } from 'react';
+import { useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastError } from '~/client/util/toastr';
 import { useIsMaintenanceMode } from '~/states/global';
+import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
@@ -28,7 +29,7 @@ const AppSettingsPageContents = (props: Props) => {
 
   const isMaintenanceMode = useIsMaintenanceMode();
 
-  const { isV5Compatible } = adminAppContainer.state;
+  const { data: appSettings } = useSWRxAppSettings();
 
   useEffect(() => {
     const fetchAppSettingsData = async () => {
@@ -73,7 +74,7 @@ const AppSettingsPageContents = (props: Props) => {
           </div>
         )
       }
-      {!isV5Compatible && (
+      {appSettings?.isV5Compatible === false && (
         <div className="row">
           <div className="col-lg-12">
             <h2

+ 6 - 7
apps/app/src/client/components/PageCreateModal.tsx

@@ -21,12 +21,14 @@ import { debounce } from 'throttle-debounce';
 import { useCreateTemplatePage } from '~/client/services/create-page';
 import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
-import { useCurrentUser, useGrowiCloudUri } from '~/states/global';
+import { useGrowiDocumentationUrl } from '~/states/context';
+import { useCurrentUser } from '~/states/global';
 import { isSearchServiceReachableAtom } from '~/states/server-configurations';
 import {
   usePageCreateModalActions,
   usePageCreateModalStatus,
 } from '~/states/ui/modal/page-create';
+import { getLocale } from '~/utils/locale-utils';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
@@ -38,7 +40,7 @@ const PageCreateModal: React.FC = () => {
   const { t, i18n } = useTranslation();
 
   const currentUser = useCurrentUser();
-  const growiCloudUri = useGrowiCloudUri();
+  const documentationUrl = useGrowiDocumentationUrl();
 
   const { isOpened, path: pathname = '' } = usePageCreateModalStatus();
   const { close: closeCreateModal } = usePageCreateModalActions();
@@ -72,11 +74,8 @@ const PageCreateModal: React.FC = () => {
     [userHomepagePath, t, now],
   );
 
-  const templateHelpLang = i18n.language === 'ja' ? 'ja' : 'en';
-  const templateHelpUrl =
-    growiCloudUri != null
-      ? `https://growi.cloud/help/${templateHelpLang}/guide/features/template.html`
-      : `https://docs.growi.org/${templateHelpLang}/guide/features/template.html`;
+  const docsLang = getLocale(i18n.language).code === 'ja' ? 'ja' : 'en';
+  const templateHelpUrl = `${documentationUrl}/${docsLang}/guide/features/template.html`;
 
   const [todayInput, setTodayInput] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);

+ 0 - 9
apps/app/src/client/services/AdminAppContainer.js

@@ -23,7 +23,6 @@ export default class AdminAppContainer extends Container {
       isEmailPublishedForNewUser: true,
       isReadOnlyForNewUser: false,
 
-      isV5Compatible: null,
       siteUrl: '',
       siteUrlUseOnlyEnvVars: null,
       envSiteUrl: '',
@@ -68,7 +67,6 @@ export default class AdminAppContainer extends Container {
       globalLang: appSettingsParams.globalLang,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       isReadOnlyForNewUser: appSettingsParams.isReadOnlyForNewUser,
-      isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
       siteUrlUseOnlyEnvVars: appSettingsParams.siteUrlUseOnlyEnvVars,
       envSiteUrl: appSettingsParams.envSiteUrl,
@@ -127,13 +125,6 @@ export default class AdminAppContainer extends Container {
     this.setState({ isReadOnlyForNewUser });
   }
 
-  /**
-   * Change site url
-   */
-  changeIsV5Compatible(isV5Compatible) {
-    this.setState({ isV5Compatible });
-  }
-
   /**
    * Change site url
    */

+ 27 - 0
apps/app/src/server/middlewares/reject-link-sharing-disabled.ts

@@ -0,0 +1,27 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { RequestHandler } from 'express';
+
+import { configManager } from '~/server/service/config-manager';
+
+import type { ApiV3Response } from '../routes/apiv3/interfaces/apiv3-response';
+
+/**
+ * Middleware that rejects requests when link sharing is globally disabled.
+ * Place before certifySharedPage to skip unnecessary DB access.
+ */
+export const rejectLinkSharingDisabled: RequestHandler = (
+  _req,
+  res: ApiV3Response,
+  next,
+) => {
+  const disableLinkSharing = configManager.getConfig(
+    'security:disableLinkSharing',
+  );
+  if (disableLinkSharing) {
+    return res.apiv3Err(
+      new ErrorV3('Link sharing is disabled', 'link-sharing-disabled'),
+      403,
+    );
+  }
+  return next();
+};

+ 1 - 0
apps/app/src/server/models/activity.ts

@@ -32,6 +32,7 @@ export interface ActivityDocument extends Document {
   event: Types.ObjectId;
   action: SupportedActionType;
   snapshot: ISnapshot;
+  createdAt: Date;
 }
 
 export interface ActivityModel extends Model<ActivityDocument> {

+ 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,

+ 119 - 0
apps/app/src/server/routes/apiv3/page/get-page-by-share-link.ts

@@ -0,0 +1,119 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { query } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { rejectLinkSharingDisabled } from '~/server/middlewares/reject-link-sharing-disabled';
+import { configManager } from '~/server/service/config-manager';
+import { findPageAndMetaDataByViewer } from '~/server/service/page/find-page-and-meta-data-by-viewer';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+import { respondWithSinglePage } from './respond-with-single-page';
+
+const logger = loggerFactory('growi:routes:apiv3:page:get-page-by-share-link');
+
+type ReqQuery = {
+  pageId: string;
+  shareLinkId: string;
+};
+
+type Req = Request<
+  Record<string, string>,
+  ApiV3Response,
+  undefined,
+  ReqQuery
+> & {
+  isSharedPage?: boolean;
+};
+
+/**
+ * @swagger
+ *
+ *    /page/shared:
+ *      get:
+ *        tags: [Page]
+ *        summary: Get page by share link
+ *        description: Get page data via a valid share link (public endpoint, no authentication required)
+ *        parameters:
+ *          - name: shareLinkId
+ *            in: query
+ *            required: true
+ *            description: share link ID
+ *            schema:
+ *              $ref: '#/components/schemas/ObjectId'
+ *          - name: pageId
+ *            in: query
+ *            required: true
+ *            description: page ID
+ *            schema:
+ *              $ref: '#/components/schemas/ObjectId'
+ *        responses:
+ *          200:
+ *            description: Successfully retrieved page via share link
+ *            content:
+ *              application/json:
+ *                schema:
+ *                  $ref: '#/components/schemas/GetPageResponse'
+ *          403:
+ *            description: Link sharing disabled, link expired, or forbidden page
+ *          404:
+ *            description: Share link not found or page not found
+ *          400:
+ *            description: Invalid or missing parameters
+ */
+export const getPageByShareLinkHandlerFactory = (
+  crowi: Crowi,
+): RequestHandler[] => {
+  const { pageService, pageGrantService } = crowi;
+  const certifySharedPage = require('../../../middlewares/certify-shared-page')(
+    crowi,
+  );
+
+  const validator = [
+    query('shareLinkId').isMongoId().withMessage('shareLinkId is required'),
+    query('pageId').isMongoId().withMessage('pageId is required'),
+  ];
+
+  return [
+    ...validator,
+    apiV3FormValidator,
+    rejectLinkSharingDisabled,
+    certifySharedPage,
+    async (req: Req, res: ApiV3Response) => {
+      const { pageId } = req.query;
+
+      if (!req.isSharedPage) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'Share link is not found or has expired',
+            'share-link-invalid',
+          ),
+          404,
+        );
+      }
+
+      try {
+        const pageWithMeta = await findPageAndMetaDataByViewer(
+          pageService,
+          pageGrantService,
+          {
+            pageId,
+            path: null,
+            user: undefined,
+            isSharedPage: true,
+          },
+        );
+
+        const disableUserPages = configManager.getConfig(
+          'security:disableUserPages',
+        );
+        return respondWithSinglePage(res, pageWithMeta, { disableUserPages });
+      } catch (err) {
+        logger.error('get-page-by-share-link-failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  ];
+};

+ 10 - 99
apps/app/src/server/routes/apiv3/page/index.ts

@@ -10,7 +10,6 @@ import type {
 import {
   AllSubscriptionStatusType,
   getIdForRef,
-  getIdStringForRef,
   isIPageNotFoundInfo,
   PageGrant,
   SCOPE,
@@ -40,7 +39,6 @@ import loginRequiredFactory from '~/server/middlewares/login-required';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
-import ShareLink from '~/server/models/share-link';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import { exportService } from '~/server/service/export';
@@ -53,10 +51,12 @@ import loggerFactory from '~/utils/logger';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
+import { getPageByShareLinkHandlerFactory } from './get-page-by-share-link';
 import { getPageInfoHandlerFactory } from './get-page-info';
 import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
+import { respondWithSinglePage } from './respond-with-single-page';
 import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
@@ -89,9 +89,6 @@ const router = express.Router();
 module.exports = (crowi: Crowi) => {
   const loginRequired = loginRequiredFactory(crowi, true);
   const loginRequiredStrictly = loginRequiredFactory(crowi);
-  const certifySharedPage = require('../../../middlewares/certify-shared-page')(
-    crowi,
-  );
   const addActivity = generateAddActivityMiddleware();
 
   const globalNotificationService = crowi.globalNotificationService;
@@ -105,7 +102,6 @@ module.exports = (crowi: Crowi) => {
       query('pageId').isMongoId().optional().isString(),
       query('path').optional().isString(),
       query('findAll').optional().isBoolean(),
-      query('shareLinkId').optional().isMongoId(),
       query('includeEmpty').optional().isBoolean(),
     ],
     likes: [body('pageId').isString(), body('bool').isBoolean()],
@@ -183,11 +179,6 @@ module.exports = (crowi: Crowi) => {
    *            description: Specific revision ID to retrieve
    *            schema:
    *              $ref: '#/components/schemas/ObjectId'
-   *          - name: shareLinkId
-   *            in: query
-   *            description: Share link ID for shared page access
-   *            schema:
-   *              $ref: '#/components/schemas/ObjectId'
    *          - name: includeEmpty
    *            in: query
    *            description: Include empty pages in results when using findAll
@@ -204,110 +195,26 @@ module.exports = (crowi: Crowi) => {
   router.get(
     '/',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    certifySharedPage,
     loginRequired,
     validator.getPage,
     apiV3FormValidator,
     async (req, res) => {
-      const { user, isSharedPage } = req;
-      const { pageId, path, findAll, revisionId, shareLinkId, includeEmpty } =
-        req.query;
+      const { user } = req;
+      const { pageId, path, findAll, revisionId, includeEmpty } = req.query;
 
       const disableUserPages = crowi.configManager.getConfig(
         'security:disableUserPages',
       );
 
-      const respondWithSinglePage = async (
-        pageWithMeta:
-          | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
-          | IDataWithMeta<null, IPageNotFoundInfo>,
-      ) => {
-        let { data: page } = pageWithMeta;
-        const { meta } = pageWithMeta;
-
-        if (isIPageNotFoundInfo(meta)) {
-          if (meta.isForbidden) {
-            return res.apiv3Err(
-              new ErrorV3(
-                'Page is forbidden',
-                'page-is-forbidden',
-                undefined,
-                meta,
-              ),
-              403,
-            );
-          }
-          return res.apiv3Err(
-            new ErrorV3('Page is not found', 'page-not-found', undefined, meta),
-            404,
-          );
-        }
-
-        if (disableUserPages && page != null) {
-          const isTargetUserPage =
-            isUserPage(page.path) || isUsersTopPage(page.path);
-
-          if (isTargetUserPage) {
-            return res.apiv3Err(
-              new ErrorV3('Page is forbidden', 'page-is-forbidden'),
-              403,
-            );
-          }
-        }
-
-        if (page != null) {
-          try {
-            page.initLatestRevisionField(revisionId);
-
-            // populate
-            page = await page.populateDataToShowRevision();
-          } catch (err) {
-            logger.error('populate-page-failed', err);
-            return res.apiv3Err(
-              new ErrorV3(
-                'Failed to populate page',
-                'populate-page-failed',
-                undefined,
-                { err, meta },
-              ),
-              500,
-            );
-          }
-        }
-
-        return res.apiv3({ page, pages: undefined, meta });
-      };
-
-      const isValid =
-        (shareLinkId != null && pageId != null && path == null) ||
-        (shareLinkId == null && (pageId != null || path != null));
+      const isValid = pageId != null || path != null;
       if (!isValid) {
         return res.apiv3Err(
-          new Error(
-            'Either parameter of (pageId or path) or (pageId and shareLinkId) is required.',
-          ),
+          new Error('Either pageId or path is required.'),
           400,
         );
       }
 
       try {
-        if (isSharedPage) {
-          const shareLink = await ShareLink.findOne({
-            _id: { $eq: shareLinkId },
-          });
-          if (shareLink == null) {
-            return res.apiv3Err('ShareLink is not found', 404);
-          }
-          return respondWithSinglePage(
-            await findPageAndMetaDataByViewer(pageService, pageGrantService, {
-              pageId: getIdStringForRef(shareLink.relatedPage),
-              path,
-              user,
-              isSharedPage: true,
-            }),
-          );
-        }
-
         if (findAll != null) {
           const pages = await Page.findByPathAndViewer(
             path,
@@ -327,11 +234,13 @@ module.exports = (crowi: Crowi) => {
         }
 
         return respondWithSinglePage(
+          res,
           await findPageAndMetaDataByViewer(pageService, pageGrantService, {
             pageId,
             path,
             user,
           }),
+          { revisionId, disableUserPages },
         );
       } catch (err) {
         logger.error('get-page-failed', err);
@@ -587,6 +496,8 @@ module.exports = (crowi: Crowi) => {
     },
   );
 
+  router.get('/shared', getPageByShareLinkHandlerFactory(crowi));
+
   router.get('/info', getPageInfoHandlerFactory(crowi));
 
   /**

+ 224 - 0
apps/app/src/server/routes/apiv3/page/respond-with-single-page.spec.ts

@@ -0,0 +1,224 @@
+import type {
+  IDataWithMeta,
+  IPageInfo,
+  IPageInfoExt,
+  IPageNotFoundInfo,
+} from '@growi/core';
+import type { HydratedDocument } from 'mongoose';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { PageDocument } from '~/server/models/page';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+// Mock logger to avoid path resolution issues in tests
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    debug: vi.fn(),
+    error: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+  }),
+}));
+
+import { respondWithSinglePage } from './respond-with-single-page';
+
+// ApiV3Response extends Express Response which requires 50+ properties (status, json, send, …).
+// Only apiv3/apiv3Err are exercised in these tests, so a full implementation is impractical.
+function createMockRes(): ApiV3Response {
+  return {
+    apiv3: vi.fn().mockReturnValue(undefined),
+    apiv3Err: vi.fn().mockReturnValue(undefined),
+  } as unknown as ApiV3Response;
+}
+
+// HydratedDocument<PageDocument> inherits Mongoose Document internals (save, $isNew, toObject, …).
+// Only path / initLatestRevisionField / populateDataToShowRevision are exercised here.
+function createMockPage(path = '/normal-page'): HydratedDocument<PageDocument> {
+  const page = {
+    path,
+    initLatestRevisionField: vi.fn(),
+    populateDataToShowRevision: vi.fn(),
+  };
+  page.populateDataToShowRevision.mockResolvedValue(page);
+  return page as unknown as HydratedDocument<PageDocument>;
+}
+
+function createPageInfo(overrides: Partial<IPageInfo> = {}): IPageInfo {
+  return {
+    isNotFound: false,
+    isV5Compatible: true,
+    isEmpty: false,
+    isMovable: true,
+    isDeletable: true,
+    isAbleToDeleteCompletely: true,
+    isRevertible: false,
+    bookmarkCount: 0,
+    ...overrides,
+  };
+}
+
+describe('respondWithSinglePage', () => {
+  let mockRes: ApiV3Response;
+  let mockPage: HydratedDocument<PageDocument>;
+
+  beforeEach(() => {
+    mockRes = createMockRes();
+    mockPage = createMockPage();
+  });
+
+  describe('success case', () => {
+    it('should return success response with page and meta when page exists', async () => {
+      // Arrange
+      const mockMeta = createPageInfo();
+      const pageWithMeta: IDataWithMeta<
+        HydratedDocument<PageDocument>,
+        IPageInfoExt
+      > = {
+        data: mockPage,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta);
+
+      // Assert
+      expect(mockRes.apiv3).toHaveBeenCalledWith(
+        expect.objectContaining({
+          page: mockPage,
+          pages: undefined,
+          meta: mockMeta,
+        }),
+      );
+      expect(mockPage.initLatestRevisionField).toHaveBeenCalledWith(undefined);
+      expect(mockPage.populateDataToShowRevision).toHaveBeenCalled();
+    });
+
+    it('should initialize revision field when revisionId is provided', async () => {
+      // Arrange
+      const revisionId = '507f1f77bcf86cd799439011';
+      const mockMeta = createPageInfo();
+      const pageWithMeta: IDataWithMeta<
+        HydratedDocument<PageDocument>,
+        IPageInfoExt
+      > = {
+        data: mockPage,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta, { revisionId });
+
+      // Assert
+      expect(mockPage.initLatestRevisionField).toHaveBeenCalledWith(revisionId);
+    });
+  });
+
+  describe('forbidden case', () => {
+    it('should return 403 when page meta has isForbidden=true', async () => {
+      // Arrange
+      const mockMeta: IPageNotFoundInfo = {
+        isNotFound: true,
+        isForbidden: true,
+      };
+      const pageWithMeta: IDataWithMeta<null, IPageNotFoundInfo> = {
+        data: null,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta);
+
+      // Assert
+      expect(mockRes.apiv3Err).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: 'Page is forbidden',
+          code: 'page-is-forbidden',
+        }),
+        403,
+      );
+      expect(mockRes.apiv3).not.toHaveBeenCalled();
+    });
+
+    it('should return 403 when disableUserPages=true and page is a user page', async () => {
+      // Arrange
+      const userPage = createMockPage('/user/john');
+      const mockMeta = createPageInfo();
+      const pageWithMeta: IDataWithMeta<
+        HydratedDocument<PageDocument>,
+        IPageInfoExt
+      > = {
+        data: userPage,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta, {
+        disableUserPages: true,
+      });
+
+      // Assert
+      expect(mockRes.apiv3Err).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: 'Page is forbidden',
+          code: 'page-is-forbidden',
+        }),
+        403,
+      );
+      expect(mockRes.apiv3).not.toHaveBeenCalled();
+    });
+
+    it('should return 403 when disableUserPages=true and page is a user top page', async () => {
+      // Arrange
+      const userTopPage = createMockPage('/user');
+      const mockMeta = createPageInfo();
+      const pageWithMeta: IDataWithMeta<
+        HydratedDocument<PageDocument>,
+        IPageInfoExt
+      > = {
+        data: userTopPage,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta, {
+        disableUserPages: true,
+      });
+
+      // Assert
+      expect(mockRes.apiv3Err).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: 'Page is forbidden',
+          code: 'page-is-forbidden',
+        }),
+        403,
+      );
+    });
+  });
+
+  describe('not-found case', () => {
+    it('should return 404 when page meta has isForbidden=false (not-found only)', async () => {
+      // Arrange
+      const mockMeta: IPageNotFoundInfo = {
+        isNotFound: true,
+        isForbidden: false,
+      };
+      const pageWithMeta: IDataWithMeta<null, IPageNotFoundInfo> = {
+        data: null,
+        meta: mockMeta,
+      };
+
+      // Act
+      await respondWithSinglePage(mockRes, pageWithMeta);
+
+      // Assert
+      expect(mockRes.apiv3Err).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: 'Page is not found',
+          code: 'page-not-found',
+        }),
+        404,
+      );
+    });
+  });
+});

+ 97 - 0
apps/app/src/server/routes/apiv3/page/respond-with-single-page.ts

@@ -0,0 +1,97 @@
+import type {
+  IDataWithMeta,
+  IPageInfoExt,
+  IPageNotFoundInfo,
+} from '@growi/core';
+import { isIPageNotFoundInfo } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import {
+  isUserPage,
+  isUsersTopPage,
+} from '@growi/core/dist/utils/page-path-utils';
+import type { HydratedDocument } from 'mongoose';
+
+import type { PageDocument } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory(
+  'growi:routes:apiv3:page:respond-with-single-page',
+);
+
+export interface RespondWithSinglePageOptions {
+  revisionId?: string;
+  disableUserPages?: boolean;
+}
+
+/**
+ * Generate and send a single page response via Express.
+ *
+ * Handles success (200), not found (404), forbidden (403), and error (500) responses.
+ * Optionally initializes revision field and checks disableUserPages setting.
+ *
+ * @param res - Express response object
+ * @param pageWithMeta - Page data with metadata (success or not-found states)
+ * @param options - Optional revisionId and disableUserPages settings
+ */
+export async function respondWithSinglePage(
+  res: ApiV3Response,
+  pageWithMeta:
+    | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+    | IDataWithMeta<null, IPageNotFoundInfo>,
+  options: RespondWithSinglePageOptions = {},
+): Promise<void> {
+  const { revisionId, disableUserPages = false } = options;
+  let { data: page } = pageWithMeta;
+  const { meta } = pageWithMeta;
+
+  // Handle not found or forbidden cases
+  if (isIPageNotFoundInfo(meta)) {
+    if (meta.isForbidden) {
+      return res.apiv3Err(
+        new ErrorV3('Page is forbidden', 'page-is-forbidden', undefined, meta),
+        403,
+      );
+    }
+    return res.apiv3Err(
+      new ErrorV3('Page is not found', 'page-not-found', undefined, meta),
+      404,
+    );
+  }
+
+  // Check disableUserPages setting
+  if (disableUserPages && page != null) {
+    const isTargetUserPage = isUserPage(page.path) || isUsersTopPage(page.path);
+
+    if (isTargetUserPage) {
+      return res.apiv3Err(
+        new ErrorV3('Page is forbidden', 'page-is-forbidden'),
+        403,
+      );
+    }
+  }
+
+  // Populate page data with revision information
+  if (page != null) {
+    try {
+      page.initLatestRevisionField(revisionId);
+
+      // populate
+      page = await page.populateDataToShowRevision();
+    } catch (err) {
+      logger.error('populate-page-failed', err);
+      return res.apiv3Err(
+        new ErrorV3(
+          'Failed to populate page',
+          'populate-page-failed',
+          undefined,
+          { err, meta },
+        ),
+        500,
+      );
+    }
+  }
+
+  return res.apiv3({ page, pages: undefined, meta });
+}

+ 45 - 20
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -1,5 +1,5 @@
 import type { IPage, IRevisionHasId, IUserHasId } from '@growi/core';
-import { allOrigin, getIdForRef, Origin } from '@growi/core';
+import { allOrigin, getIdForRef, getIdStringForRef, Origin } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
@@ -32,6 +32,7 @@ import {
   serializePageSecurely,
   serializeRevisionSecurely,
 } from '~/server/models/serializers';
+import { shouldGenerateUpdate } from '~/server/service/activity/update-activity-logic';
 import { configManager } from '~/server/service/config-manager/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -118,24 +119,49 @@ export const updatePageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
       await yjsService.syncWithTheLatestRevisionForce(req.body.pageId);
     }
 
-    // persist activity
-    const creator =
-      updatedPage.creator != null
-        ? getIdForRef(updatedPage.creator)
-        : undefined;
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: updatedPage,
-      action: SupportedAction.ACTION_PAGE_UPDATE,
-    };
-    const activityEvent = crowi.events.activity;
-    activityEvent.emit(
-      'update',
-      res.locals.activity._id,
-      parameters,
-      { path: updatedPage.path, creator },
-      preNotifyService.generatePreNotify,
-    );
+    // Decide if update activity should generate
+    let shouldGenerateUpdateActivity = false;
+    try {
+      const targetPageId = getIdStringForRef(updatedPage);
+      const currentActivityId = getIdStringForRef(res.locals.activity);
+      const currentUserId = req.user ? getIdStringForRef(req.user) : undefined;
+
+      shouldGenerateUpdateActivity = await shouldGenerateUpdate({
+        currentUserId,
+        targetPageId,
+        currentActivityId,
+      });
+    } catch (err) {
+      logger.error(
+        'Failed to determine whether to generate update activity.',
+        err,
+      );
+    }
+
+    if (shouldGenerateUpdateActivity) {
+      try {
+        // persist activity
+        const creator =
+          updatedPage.creator != null
+            ? getIdForRef(updatedPage.creator)
+            : undefined;
+        const parameters = {
+          targetModel: SupportedTargetModel.MODEL_PAGE,
+          target: updatedPage,
+          action: SupportedAction.ACTION_PAGE_UPDATE,
+        };
+        const activityEvent = crowi.events.activity;
+        activityEvent.emit(
+          'update',
+          res.locals.activity._id,
+          parameters,
+          { path: updatedPage.path, creator },
+          preNotifyService.generatePreNotify,
+        );
+      } catch (err) {
+        logger.error('Failed to generate update activity', err);
+      }
+    }
 
     // global notification
     try {
@@ -283,7 +309,6 @@ export const updatePageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
           409,
         );
       }
-
       let updatedPage: HydratedDocument<PageDocument>;
       let previousRevision: IRevisionHasId | null;
       try {

+ 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,

+ 1 - 0
apps/app/src/server/service/activity/index.ts

@@ -0,0 +1 @@
+export * from './update-activity-logic';

+ 59 - 0
apps/app/src/server/service/activity/update-activity-logic.ts

@@ -0,0 +1,59 @@
+import type { IRevisionHasId } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
+import mongoose from 'mongoose';
+
+import { SupportedAction } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
+
+type GenerateUpdatePayload = {
+  currentUserId: string | undefined;
+  targetPageId: string;
+  currentActivityId: string;
+};
+
+const MINIMUM_REVISION_FOR_ACTIVITY = 2;
+const SUPPRESION_UPDATE_WINDOW_MS = 5 * 60 * 1000; // 5 min
+
+export const shouldGenerateUpdate = async (payload: GenerateUpdatePayload) => {
+  const { targetPageId, currentActivityId, currentUserId } = payload;
+
+  if (currentUserId == null) {
+    return false;
+  }
+
+  // Get most recent update or create activity on the page
+  const lastContentActivity = await Activity.findOne({
+    target: targetPageId,
+    action: {
+      $in: [
+        SupportedAction.ACTION_PAGE_CREATE,
+        SupportedAction.ACTION_PAGE_UPDATE,
+      ],
+    },
+    _id: { $ne: currentActivityId },
+  }).sort({ createdAt: -1 });
+
+  const isLastActivityByMe =
+    lastContentActivity != null &&
+    getIdStringForRef(lastContentActivity?.user) === currentUserId;
+  const lastActivityTime = lastContentActivity?.createdAt?.getTime?.() ?? 0;
+  const timeSinceLastActivityMs = Date.now() - lastActivityTime;
+
+  // Decide if update activity should generate
+  let shouldGenerateUpdateActivity: boolean;
+  if (!isLastActivityByMe) {
+    shouldGenerateUpdateActivity = true;
+  } else if (timeSinceLastActivityMs < SUPPRESION_UPDATE_WINDOW_MS) {
+    shouldGenerateUpdateActivity = false;
+  } else {
+    const Revision = mongoose.model<IRevisionHasId>('Revision');
+    const revisionCount = await Revision.countDocuments({
+      pageId: targetPageId,
+    });
+
+    shouldGenerateUpdateActivity =
+      revisionCount > MINIMUM_REVISION_FOR_ACTIVITY;
+  }
+
+  return shouldGenerateUpdateActivity;
+};

+ 460 - 0
apps/app/src/server/service/activity/update-activity.spec.ts

@@ -0,0 +1,460 @@
+import { MongoMemoryServer } from 'mongodb-memory-server-core';
+import mongoose from 'mongoose';
+
+import { SupportedAction } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
+import { Revision } from '~/server/models/revision';
+
+import { shouldGenerateUpdate } from './update-activity-logic';
+
+describe('shouldGenerateUpdate()', () => {
+  let mongoServer: MongoMemoryServer;
+
+  let date = new Date();
+  const TWO_HOURS = 2 * 60 * 60 * 1000;
+  const ONE_HOUR = 60 * 60 * 1000;
+  const ONE_MINUTE = 1 * 60 * 1000;
+
+  let targetPageId: mongoose.Types.ObjectId;
+  let currentUserId: mongoose.Types.ObjectId;
+  let otherUserId: mongoose.Types.ObjectId;
+  let currentActivityId: mongoose.Types.ObjectId;
+  let olderActivityId: mongoose.Types.ObjectId;
+  let createActivityId: mongoose.Types.ObjectId;
+
+  let targetPageIdStr: string;
+  let currentUserIdStr: string;
+  let currentActivityIdStr: string;
+
+  beforeAll(async () => {
+    mongoServer = await MongoMemoryServer.create();
+    await mongoose.connect(mongoServer.getUri());
+  });
+
+  afterAll(async () => {
+    await mongoose.disconnect();
+    await mongoServer.stop();
+  });
+
+  beforeEach(async () => {
+    await Activity.deleteMany({});
+    await Revision.deleteMany({});
+
+    // Reset date and IDs between tests
+    date = new Date();
+    targetPageId = new mongoose.Types.ObjectId();
+    currentUserId = new mongoose.Types.ObjectId();
+    otherUserId = new mongoose.Types.ObjectId();
+    currentActivityId = new mongoose.Types.ObjectId();
+    olderActivityId = new mongoose.Types.ObjectId();
+    createActivityId = new mongoose.Types.ObjectId();
+
+    targetPageIdStr = targetPageId.toString();
+    currentUserIdStr = currentUserId.toString();
+    currentActivityIdStr = currentActivityId.toString();
+  });
+
+  it('should not generate update activity if: a create was performed but no update made', async () => {
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(false);
+  });
+
+  it('should generate update activity if: latest update is by another user, not first update', async () => {
+    await Activity.insertMany([
+      // Create activity
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      // Latest activity
+      {
+        user: otherUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(date.getTime() - ONE_HOUR),
+        target: targetPageId,
+        _id: olderActivityId,
+      },
+      // Current activity
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    // More than 2 revisions means it is NOT the first update
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(true);
+  });
+
+  it('should generate update activity if: page created by another user, first update', async () => {
+    await Activity.insertMany([
+      {
+        user: otherUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+    expect(result).toBe(true);
+  });
+
+  it('should not generate update activity if: update is made by the page creator, outside suppression window, first update', async () => {
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - ONE_HOUR),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(false);
+  });
+
+  it('should not generate update activity if: update is made by the page creator, within suppression window, first update', async () => {
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - ONE_MINUTE),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(false);
+  });
+
+  it('should not generate update activity if: update is made by the same user, within suppression window, not first update', async () => {
+    const FOUR_MINUTES = 4 * 60 * 1000;
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(date.getTime() - FOUR_MINUTES),
+        target: targetPageId,
+        _id: olderActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(false);
+  });
+
+  it('should generate update activity if: update is made by the same user, outside suppression window, not first update', async () => {
+    const SIX_MINUTES = 6 * 60 * 1000;
+    await Activity.insertMany([
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - TWO_HOURS),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(date.getTime() - SIX_MINUTES),
+        target: targetPageId,
+        _id: olderActivityId,
+      },
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(true);
+  });
+
+  it('should not care about edits on other pages', async () => {
+    const otherPageId = new mongoose.Types.ObjectId();
+
+    await Activity.insertMany([
+      // Create page
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+        createdAt: new Date(date.getTime() - ONE_HOUR),
+        target: targetPageId,
+        _id: createActivityId,
+      },
+      // Update other page
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(date.getTime() - ONE_MINUTE),
+        target: otherPageId,
+        _id: new mongoose.Types.ObjectId(),
+      },
+      // Update previously created page
+      {
+        user: currentUserId,
+        action: SupportedAction.ACTION_PAGE_UPDATE,
+        createdAt: new Date(),
+        target: targetPageId,
+        _id: currentActivityId,
+      },
+    ]);
+
+    await Revision.insertMany([
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Old content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+      {
+        _id: new mongoose.Types.ObjectId(),
+        pageId: targetPageId,
+        body: 'Newer content',
+        format: 'markdown',
+        author: currentUserId,
+      },
+    ]);
+
+    const result = await shouldGenerateUpdate({
+      targetPageId: targetPageIdStr,
+      currentUserId: currentUserIdStr,
+      currentActivityId: currentActivityIdStr,
+    });
+
+    expect(result).toBe(true);
+  });
+});

+ 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.

+ 5 - 5
apps/app/src/states/page/use-fetch-current-page.spec.tsx

@@ -1072,10 +1072,10 @@ describe('useFetchCurrentPage - Integration Test', () => {
       });
     });
 
-    // Assert: API should be called with pageId (not path=/share/...) and shareLinkId
+    // Assert: API should be called with /page/shared endpoint, pageId (not path=/share/...) and shareLinkId
     await waitFor(() => {
       expect(mockedApiv3Get).toHaveBeenCalledWith(
-        '/page',
+        '/page/shared',
         expect.objectContaining({
           pageId,
           shareLinkId,
@@ -1083,7 +1083,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
       // path should NOT be sent
       expect(mockedApiv3Get).toHaveBeenCalledWith(
-        '/page',
+        '/page/shared',
         expect.not.objectContaining({ path: expect.anything() }),
       );
       expect(store.get(currentPageEntityIdAtom)).toBe(pageId);
@@ -1109,10 +1109,10 @@ describe('useFetchCurrentPage - Integration Test', () => {
       await result.current.fetchCurrentPage({ path: '/some/path' });
     });
 
-    // Assert: Falls through to path-based logic since currentPageId is null
+    // Assert: Falls through to path-based logic since currentPageId is null, but still uses /page/shared
     await waitFor(() => {
       expect(mockedApiv3Get).toHaveBeenCalledWith(
-        '/page',
+        '/page/shared',
         expect.objectContaining({
           path: '/some/path',
           shareLinkId,

+ 5 - 2
apps/app/src/states/page/use-fetch-current-page.ts

@@ -147,7 +147,7 @@ const buildApiParams = ({
   if (fetchPageArgs?.pageId != null) {
     params.pageId = fetchPageArgs.pageId;
   }
-  // priority B: currentPageId for share link (required by certifySharedPage middleware)
+  // priority B: currentPageId for share link (required by /page/shared endpoint)
   else if (shareLinkId != null && currentPageId != null) {
     params.pageId = currentPageId;
   } else if (decodedPathname != null) {
@@ -243,7 +243,10 @@ export const useFetchCurrentPage = (): {
         }
 
         try {
-          const { data } = await apiv3Get<FetchedPageResult>('/page', params);
+          // Use dedicated /page/shared endpoint for share link access
+          const endpoint =
+            params.shareLinkId != null ? '/page/shared' : '/page';
+          const { data } = await apiv3Get<FetchedPageResult>(endpoint, params);
           const { page: newData, meta } = data;
 
           set(currentPageDataAtom, newData ?? undefined);

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -46,7 +46,7 @@
     "@tsed/schema": "=6.43.0",
     "@tsed/swagger": "=6.43.0",
     "@tsed/typeorm": "=6.43.0",
-    "axios": "^1.11.0",
+    "axios": "^1.15.0",
     "body-parser": "^1.20.3",
     "compression": "^1.7.4",
     "cookie-parser": "^1.4.5",

+ 14 - 2
package.json

@@ -90,13 +90,25 @@
   "// comments for pnpm.overrides": {
     "@lykmapipo/common>flat": "flat v6 is provided only by ESM, but @lykmapipo/common requires CommonJS version",
     "@lykmapipo/common>mime": "mime v4 is provided only by ESM, but @lykmapipo/common requires CommonJS version",
-    "@lykmapipo/common>parse-json": "parse-json v6 is provided only by ESM, but @lykmapipo/common requires CommonJS version"
+    "@lykmapipo/common>parse-json": "parse-json v6 is provided only by ESM, but @lykmapipo/common requires CommonJS version",
+    "axios": "CVE-2025-XXXXX: CRLF Injection + Prototype Pollution combo leads to HTTP Request Smuggling (CVSS 10.0). All versions < 1.15.0 are vulnerable."
+  },
+  "// comments for pnpm.packageExtensions": {
+    "@orval/core": "@orval/core bundles @stoplight/json-ref-resolver which requires lodash/get at runtime, but @orval/core does not declare lodash as a dependency"
   },
   "pnpm": {
     "overrides": {
       "@lykmapipo/common>flat": "5.0.2",
       "@lykmapipo/common>mime": "3.0.0",
-      "@lykmapipo/common>parse-json": "5.2.0"
+      "@lykmapipo/common>parse-json": "5.2.0",
+      "axios": "^1.15.0"
+    },
+    "packageExtensions": {
+      "@orval/core": {
+        "dependencies": {
+          "lodash": "*"
+        }
+      }
     },
     "ignoredBuiltDependencies": [
       "@swc/core",

+ 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();

+ 1 - 1
packages/logger/package.json

@@ -16,7 +16,7 @@
   },
   "scripts": {
     "build": "vite build",
-    "clean": "shx rm -rf dist",
+    "clean": "rimraf dist",
     "dev": "vite build --mode dev",
     "watch": "pnpm run dev -w --emptyOutDir=false",
     "lint:biome": "biome check",

+ 1 - 1
packages/pdf-converter-client/package.json

@@ -12,7 +12,7 @@
     "build": "pnpm gen:client-code && tsc -p tsconfig.json"
   },
   "dependencies": {
-    "axios": "^1.11.0",
+    "axios": "^1.15.0",
     "tslib": "^2.8.0"
   },
   "devDependencies": {

+ 2 - 2
packages/presentation/src/client/components/GrowiSlides.tsx

@@ -24,11 +24,11 @@ export const GrowiSlides = (props: Props): JSX.Element => {
   const { rendererOptions, isDarkMode, disableSeparationByHeader } = options;
 
   if (
-    rendererOptions == null ||
     rendererOptions.remarkPlugins == null ||
     rendererOptions.components == null
   ) {
-    return;
+    // biome-ignore lint/complexity/noUselessFragments: This is for type checking only. The actual code will never reach here.
+    return <></>;
   }
 
   rendererOptions.remarkPlugins.push([

+ 1 - 1
packages/remark-attachment-refs/package.json

@@ -48,7 +48,7 @@
     "@growi/logger": "workspace:^",
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/ui": "workspace:^",
-    "axios": "^1.11.0",
+    "axios": "^1.15.0",
     "express": "^4.20.0",
     "hast-util-select": "^6.0.2",
     "mongoose": "^6.13.6",

+ 1 - 1
packages/remark-lsx/package.json

@@ -45,7 +45,7 @@
   "devDependencies": {
     "@types/express": "^4",
     "@types/hast": "^3.0.4",
-    "axios": "^1.11.0",
+    "axios": "^1.15.0",
     "hast-util-sanitize": "^5.0.1",
     "hast-util-select": "^6.0.2",
     "is-absolute-url": "^4.0.1",

+ 1 - 1
packages/slack/package.json

@@ -54,7 +54,7 @@
     "@slack/web-api": "^6.2.4",
     "@types/http-errors": "^2.0.3",
     "@types/url-join": "^4.0.2",
-    "axios": "^1.11.0",
+    "axios": "^1.15.0",
     "crypto": "^1.0.1",
     "date-fns": "^3.6.0",
     "extensible-custom-error": "^0.0.7",

+ 44 - 40
pnpm-lock.yaml

@@ -8,6 +8,9 @@ overrides:
   '@lykmapipo/common>flat': 5.0.2
   '@lykmapipo/common>mime': 3.0.0
   '@lykmapipo/common>parse-json': 5.2.0
+  axios: ^1.15.0
+
+packageExtensionsChecksum: sha256-8AVTVG6XqA8Sdx2pyiL0NkxKdDqAh83Jgy7TwdlUUks=
 
 patchedDependencies:
   '@marp-team/marp-core':
@@ -343,8 +346,8 @@ importers:
         specifier: ^1.0.3
         version: 1.0.3
       axios:
-        specifier: ^1.11.0
-        version: 1.11.0
+        specifier: ^1.15.0
+        version: 1.15.0
       axios-retry:
         specifier: ^3.2.4
         version: 3.9.1
@@ -1125,8 +1128,8 @@ importers:
         specifier: '=6.43.0'
         version: 6.43.0(typeorm@0.2.45(mysql2@2.3.3)(redis@3.1.2))
       axios:
-        specifier: ^1.11.0
-        version: 1.11.0
+        specifier: ^1.15.0
+        version: 1.15.0
       body-parser:
         specifier: ^1.20.3
         version: 1.20.3
@@ -1406,8 +1409,8 @@ importers:
   packages/pdf-converter-client:
     dependencies:
       axios:
-        specifier: ^1.11.0
-        version: 1.11.0
+        specifier: ^1.15.0
+        version: 1.15.0
       tslib:
         specifier: ^2.8.0
         version: 2.8.1
@@ -1542,8 +1545,8 @@ importers:
         specifier: workspace:^
         version: link:../ui
       axios:
-        specifier: ^1.11.0
-        version: 1.11.0
+        specifier: ^1.15.0
+        version: 1.15.0
       express:
         specifier: ^4.20.0
         version: 4.21.0
@@ -1762,8 +1765,8 @@ importers:
         specifier: ^3.0.4
         version: 3.0.4
       axios:
-        specifier: ^1.11.0
-        version: 1.11.0
+        specifier: ^1.15.0
+        version: 1.15.0
       hast-util-sanitize:
         specifier: ^5.0.1
         version: 5.0.1
@@ -1801,8 +1804,8 @@ importers:
         specifier: ^4.0.2
         version: 4.0.3
       axios:
-        specifier: ^1.11.0
-        version: 1.11.0
+        specifier: ^1.15.0
+        version: 1.15.0
       crypto:
         specifier: ^1.0.1
         version: 1.0.1
@@ -6249,14 +6252,8 @@ packages:
   axios-retry@3.9.1:
     resolution: {integrity: sha512-8PJDLJv7qTTMMwdnbMvrLYuvB47M81wRtxQmEdV5w4rgbTXTt+vtPkXwajOfOdSyv/wZICJOC+/UhXH4aQ/R+w==}
 
-  axios@0.21.4:
-    resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==}
-
-  axios@0.26.1:
-    resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==}
-
-  axios@1.11.0:
-    resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==}
+  axios@1.15.0:
+    resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
 
   b4a@1.6.6:
     resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==}
@@ -8438,6 +8435,10 @@ packages:
     resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
     engines: {node: '>= 6'}
 
+  form-data@4.0.5:
+    resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
+    engines: {node: '>= 6'}
+
   format@0.2.2:
     resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
     engines: {node: '>=0.4.x'}
@@ -11521,6 +11522,10 @@ packages:
   proxy-from-env@1.1.0:
     resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
 
+  proxy-from-env@2.1.0:
+    resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
+    engines: {node: '>=10'}
+
   prr@1.0.1:
     resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
 
@@ -16439,7 +16444,7 @@ snapshots:
 
   '@keycloak/keycloak-admin-client@18.0.2':
     dependencies:
-      axios: 0.26.1
+      axios: 1.15.0
       camelize-ts: 1.0.9
       keycloak-js: 17.0.1
       lodash: 4.17.23
@@ -17582,6 +17587,7 @@ snapshots:
       esutils: 2.0.3
       fs-extra: 11.2.0
       globby: 11.1.0
+      lodash: 4.17.23
       lodash.get: 4.4.2
       lodash.isempty: 4.4.0
       lodash.omit: 4.5.0
@@ -17961,7 +17967,7 @@ snapshots:
       '@slack/types': 2.14.0
       '@types/is-stream': 1.1.0
       '@types/node': 20.19.17
-      axios: 1.11.0
+      axios: 1.15.0
       eventemitter3: 3.1.2
       form-data: 2.5.1
       is-electron: 2.2.2
@@ -17977,7 +17983,7 @@ snapshots:
       '@slack/types': 2.14.0
       '@types/node': 20.19.17
       '@types/retry': 0.12.0
-      axios: 1.11.0
+      axios: 1.15.0
       eventemitter3: 5.0.1
       form-data: 4.0.4
       is-electron: 2.2.2
@@ -17992,7 +17998,7 @@ snapshots:
     dependencies:
       '@slack/types': 1.10.0
       '@types/node': 20.19.17
-      axios: 0.21.4
+      axios: 1.15.0
     transitivePeerDependencies:
       - debug
 
@@ -19024,7 +19030,7 @@ snapshots:
       '@types/fs-extra': 11.0.4
       '@types/inquirer': 9.0.7
       ajv: 8.18.0
-      axios: 1.11.0
+      axios: 1.15.0
       chalk: 5.3.0
       change-case: 5.4.4
       commander: 12.1.0
@@ -20506,23 +20512,11 @@ snapshots:
       '@babel/runtime': 7.28.6
       is-retry-allowed: 2.2.0
 
-  axios@0.21.4:
+  axios@1.15.0:
     dependencies:
       follow-redirects: 1.15.11(debug@4.4.3)
-    transitivePeerDependencies:
-      - debug
-
-  axios@0.26.1:
-    dependencies:
-      follow-redirects: 1.15.11(debug@4.4.3)
-    transitivePeerDependencies:
-      - debug
-
-  axios@1.11.0:
-    dependencies:
-      follow-redirects: 1.15.11(debug@4.4.3)
-      form-data: 4.0.4
-      proxy-from-env: 1.1.0
+      form-data: 4.0.5
+      proxy-from-env: 2.1.0
     transitivePeerDependencies:
       - debug
 
@@ -22675,6 +22669,14 @@ snapshots:
       hasown: 2.0.2
       mime-types: 2.1.35
 
+  form-data@4.0.5:
+    dependencies:
+      asynckit: 0.4.0
+      combined-stream: 1.0.8
+      es-set-tostringtag: 2.1.0
+      hasown: 2.0.2
+      mime-types: 2.1.35
+
   format@0.2.2: {}
 
   formdata-node@4.4.1:
@@ -26313,6 +26315,8 @@ snapshots:
 
   proxy-from-env@1.1.0: {}
 
+  proxy-from-env@2.1.0: {}
+
   prr@1.0.1:
     optional: true