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

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
 - Extract utilities from large components
 - Organize by feature/domain, not by type
 - 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
 ## Naming Conventions
 
 
 ### Variables and Functions
 ### Variables and Functions
@@ -236,3 +327,6 @@ Before marking work complete:
 - [ ] Named exports (except Next.js pages)
 - [ ] Named exports (except Next.js pages)
 - [ ] English comments
 - [ ] English comments
 - [ ] Co-located tests
 - [ ] 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
 ## 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
 ### 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
 - 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
 ### 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)
 - 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`)
 - 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
 ## 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
 - 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
 ### Architecture Pattern & Boundary Map
 
 
@@ -88,7 +88,7 @@ graph TB
 ```
 ```
 
 
 **Architecture Integration**:
 **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)`
 - 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
 - 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)
 - 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()` | — |
 | 6.1–6.4 | HTTP request logging | HttpLoggerMiddleware | `createHttpLogger()` | — |
 | 7.1–7.3 | OpenTelemetry integration | DiagLoggerPinoAdapter | `DiagLogger` interface | — |
 | 7.1–7.3 | OpenTelemetry integration | DiagLoggerPinoAdapter | `DiagLogger` interface | — |
 | 8.1–8.5 | Multi-app consistency | @growi/logger package | Package exports | — |
 | 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 |
 | 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 |
 | 12.1–12.6 | Bunyan-like output format | BunyanFormatTransport, TransportFactory | Custom transport target | Logger Creation |
 | 13.1–13.5 | HTTP logger encapsulation | HttpLoggerFactory | `createHttpLoggerMiddleware()` | — |
 | 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)
 - Logger initialization errors are written to `process.stderr` directly (cannot use the logger itself)
 - No additional monitoring infrastructure required — this is the monitoring infrastructure
 - 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
 ## Summary
-- **Feature**: `migrate-logger-to-pino`
+- **Feature**: `growi-logger`
 - **Discovery Scope**: Complex Integration
 - **Discovery Scope**: Complex Integration
 - **Key Findings**:
 - **Key Findings**:
   - Pino and bunyan share identical argument patterns (`logger.info(obj, msg)`) — no call-site changes needed
   - 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",
   "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",
   "language": "en",
   "phase": "implementation-complete",
   "phase": "implementation-complete",
   "cleanup_completed": true,
   "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 上部にフィルタボタン(「すべて」「通知」「お知らせ」)を配置し、デフォルトは「すべて」とする。「お知らせ」選択時はニュースのみ、「通知」選択時はニュース以外のすべての通知を表示する
 2. The InAppNotificationパネル shall 上部にフィルタボタン(「すべて」「通知」「お知らせ」)を配置し、デフォルトは「すべて」とする。「お知らせ」選択時はニュースのみ、「通知」選択時はニュース以外のすべての通知を表示する
 3. The InAppNotificationパネル shall 既存の「未読のみ」トグルスイッチを維持し、種別フィルタと組み合わせた2重フィルタリングを提供する。種別フィルタ(すべて/通知/お知らせ)で表示対象を絞り込んだ上で、トグルON時は未読アイテムのみをさらに絞り込む
 3. The InAppNotificationパネル shall 既存の「未読のみ」トグルスイッチを維持し、種別フィルタと組み合わせた2重フィルタリングを提供する。種別フィルタ(すべて/通知/お知らせ)で表示対象を絞り込んだ上で、トグルON時は未読アイテムのみをさらに絞り込む
 4. The InAppNotificationパネル shall リスト領域に最大高さを設定し、超過分はスクロールで表示する。スクロールが末端に達した場合は次のページを自動で読み込む無限スクロールとする
 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 を新しいタブで開く
 6. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall ニュースの詳細 URL を新しいタブで開く
 7. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall 該当ニュースを既読としてマークし、未読インジケータを更新する
 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",
   "created_at": "2026-03-24T00:00:00Z",
   "updated_at": "2026-03-24T01:00:00Z",
   "updated_at": "2026-03-24T01:00:00Z",
   "language": "ja",
   "language": "ja",
-  "phase": "requirements-generated",
+  "phase": "tasks-generated",
   "approvals": {
   "approvals": {
     "requirements": {
     "requirements": {
       "generated": true,
       "generated": true,
-      "approved": false
+      "approved": true
     },
     },
     "design": {
     "design": {
-      "generated": false,
-      "approved": false
+      "generated": true,
+      "approved": true
     },
     },
     "tasks": {
     "tasks": {
-      "generated": false,
+      "generated": true,
       "approved": false
       "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.
 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.
 **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)
 ### 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",
   "feature_name": "suggest-path",
   "created_at": "2026-02-10T12:00:00Z",
   "created_at": "2026-02-10T12:00:00Z",
-  "updated_at": "2026-03-23T00:00:00Z",
+  "updated_at": "2026-04-15T00:00:00Z",
   "language": "en",
   "language": "en",
   "phase": "implementation-complete",
   "phase": "implementation-complete",
   "approvals": {
   "approvals": {

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

@@ -20,8 +20,6 @@
 
 
 ## Post-Implementation Refactoring (from code review)
 ## Post-Implementation Refactoring (from code review)
 
 
-See `gap-analysis.md` for detailed rationale.
-
 - [x] 8. Simplify service layer abstractions
 - [x] 8. Simplify service layer abstractions
 - [x] 8.1 Remove `GenerateSuggestionsDeps` DI pattern from `generate-suggestions.ts`
 - [x] 8.1 Remove `GenerateSuggestionsDeps` DI pattern from `generate-suggestions.ts`
 - [x] 8.2 Remove `RetrieveSearchCandidatesOptions` from `retrieve-search-candidates.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)
 # Documentation (no .md files are needed for build)
 # ============================================================
 # ============================================================
 **/*.md
 **/*.md
+# Keep locale template .md files required at runtime by the installer
+!apps/app/resource/locales/**/*.md
 
 
 # ============================================================
 # ============================================================
 # Local environment overrides
 # Local environment overrides

+ 1 - 1
apps/app/package.json

@@ -121,7 +121,7 @@
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
-    "axios": "^1.11.0",
+    "axios": "^1.15.0",
     "axios-retry": "^3.2.4",
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
     "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: 'Haberion', name: 'hakumizuki' },
           { position: 'Undefined', name: 'miya' },
           { position: 'Undefined', name: 'miya' },
           { position: 'Hoimi Slime', name: 'satof3' },
           { position: 'Hoimi Slime', name: 'satof3' },
+          { position: 'Archer', name: 'Ryosei-Fukushima' },
         ],
         ],
       },
       },
       {
       {
@@ -172,9 +173,15 @@ const contributors = [
         additionalClass: 'col-md-4 my-4',
         additionalClass: 'col-md-4 my-4',
         members: [
         members: [
           { name: 'Crowi Team' },
           { name: 'Crowi Team' },
-          { position: 'Ambassador', name: 'Tsuyoshi Suzuki' },
+          { name: 'RIKEN' },
           { name: 'JPCERT/CC' },
           { 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 { useTranslation } from 'next-i18next';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import { useIsMaintenanceMode } from '~/states/global';
 import { useIsMaintenanceMode } from '~/states/global';
+import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -28,7 +29,7 @@ const AppSettingsPageContents = (props: Props) => {
 
 
   const isMaintenanceMode = useIsMaintenanceMode();
   const isMaintenanceMode = useIsMaintenanceMode();
 
 
-  const { isV5Compatible } = adminAppContainer.state;
+  const { data: appSettings } = useSWRxAppSettings();
 
 
   useEffect(() => {
   useEffect(() => {
     const fetchAppSettingsData = async () => {
     const fetchAppSettingsData = async () => {
@@ -73,7 +74,7 @@ const AppSettingsPageContents = (props: Props) => {
           </div>
           </div>
         )
         )
       }
       }
-      {!isV5Compatible && (
+      {appSettings?.isV5Compatible === false && (
         <div className="row">
         <div className="row">
           <div className="col-lg-12">
           <div className="col-lg-12">
             <h2
             <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 { useCreateTemplatePage } from '~/client/services/create-page';
 import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 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 { isSearchServiceReachableAtom } from '~/states/server-configurations';
 import {
 import {
   usePageCreateModalActions,
   usePageCreateModalActions,
   usePageCreateModalStatus,
   usePageCreateModalStatus,
 } from '~/states/ui/modal/page-create';
 } from '~/states/ui/modal/page-create';
+import { getLocale } from '~/utils/locale-utils';
 
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 
@@ -38,7 +40,7 @@ const PageCreateModal: React.FC = () => {
   const { t, i18n } = useTranslation();
   const { t, i18n } = useTranslation();
 
 
   const currentUser = useCurrentUser();
   const currentUser = useCurrentUser();
-  const growiCloudUri = useGrowiCloudUri();
+  const documentationUrl = useGrowiDocumentationUrl();
 
 
   const { isOpened, path: pathname = '' } = usePageCreateModalStatus();
   const { isOpened, path: pathname = '' } = usePageCreateModalStatus();
   const { close: closeCreateModal } = usePageCreateModalActions();
   const { close: closeCreateModal } = usePageCreateModalActions();
@@ -72,11 +74,8 @@ const PageCreateModal: React.FC = () => {
     [userHomepagePath, t, now],
     [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 [todayInput, setTodayInput] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   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,
       isEmailPublishedForNewUser: true,
       isReadOnlyForNewUser: false,
       isReadOnlyForNewUser: false,
 
 
-      isV5Compatible: null,
       siteUrl: '',
       siteUrl: '',
       siteUrlUseOnlyEnvVars: null,
       siteUrlUseOnlyEnvVars: null,
       envSiteUrl: '',
       envSiteUrl: '',
@@ -68,7 +67,6 @@ export default class AdminAppContainer extends Container {
       globalLang: appSettingsParams.globalLang,
       globalLang: appSettingsParams.globalLang,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       isReadOnlyForNewUser: appSettingsParams.isReadOnlyForNewUser,
       isReadOnlyForNewUser: appSettingsParams.isReadOnlyForNewUser,
-      isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
       siteUrl: appSettingsParams.siteUrl,
       siteUrlUseOnlyEnvVars: appSettingsParams.siteUrlUseOnlyEnvVars,
       siteUrlUseOnlyEnvVars: appSettingsParams.siteUrlUseOnlyEnvVars,
       envSiteUrl: appSettingsParams.envSiteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
@@ -127,13 +125,6 @@ export default class AdminAppContainer extends Container {
     this.setState({ isReadOnlyForNewUser });
     this.setState({ isReadOnlyForNewUser });
   }
   }
 
 
-  /**
-   * Change site url
-   */
-  changeIsV5Compatible(isV5Compatible) {
-    this.setState({ isV5Compatible });
-  }
-
   /**
   /**
    * Change site url
    * 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;
   event: Types.ObjectId;
   action: SupportedActionType;
   action: SupportedActionType;
   snapshot: ISnapshot;
   snapshot: ISnapshot;
+  createdAt: Date;
 }
 }
 
 
 export interface ActivityModel extends Model<ActivityDocument> {
 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 loginRequiredFactory from '~/server/middlewares/login-required';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
 import { getTranslation } from '~/server/service/i18next';
+import { createSMTPClient } from '~/server/service/mail/smtp';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
@@ -724,33 +725,13 @@ module.exports = (crowi: Crowi) => {
       throw Error('fromAddress is not setup');
       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 = {
     const mailOptions = {
       from: fromAddress,
       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 { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import httpErrorHandler from '../../middlewares/http-error-handler';
 import httpErrorHandler from '../../middlewares/http-error-handler';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
 
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword');
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword');
@@ -85,6 +86,7 @@ module.exports = (crowi) => {
 
 
   const checkPassportStrategyMiddleware =
   const checkPassportStrategyMiddleware =
     checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
     checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
+  const ALLOWED_TEMPLATE_NAMES = ['passwordReset', 'passwordResetSuccessful'];
 
 
   async function sendPasswordResetEmail(
   async function sendPasswordResetEmail(
     templateFileName,
     templateFileName,
@@ -93,13 +95,19 @@ module.exports = (crowi) => {
     url,
     url,
     expiredAt,
     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({
     return mailService.send({
       to: email,
       to: email,
       subject: '[GROWI] Password Reset',
       subject: '[GROWI] Password Reset',
-      template: join(
-        crowi.localeDir,
-        `${locale}/notifications/${templateFileName}.ejs`,
-      ),
+      template: templatePath,
       vars: {
       vars: {
         appTitle: appService.getAppTitle(),
         appTitle: appService.getAppTitle(),
         email,
         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 {
 import {
   AllSubscriptionStatusType,
   AllSubscriptionStatusType,
   getIdForRef,
   getIdForRef,
-  getIdStringForRef,
   isIPageNotFoundInfo,
   isIPageNotFoundInfo,
   PageGrant,
   PageGrant,
   SCOPE,
   SCOPE,
@@ -40,7 +39,6 @@ import loginRequiredFactory from '~/server/middlewares/login-required';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 import { Revision } from '~/server/models/revision';
-import ShareLink from '~/server/models/share-link';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import { exportService } from '~/server/service/export';
 import { exportService } from '~/server/service/export';
@@ -53,10 +51,12 @@ import loggerFactory from '~/utils/logger';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
 import { createPageHandlersFactory } from './create-page';
+import { getPageByShareLinkHandlerFactory } from './get-page-by-share-link';
 import { getPageInfoHandlerFactory } from './get-page-info';
 import { getPageInfoHandlerFactory } from './get-page-info';
 import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count';
 import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
 import { publishPageHandlersFactory } from './publish-page';
+import { respondWithSinglePage } from './respond-with-single-page';
 import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
 import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
 import { updatePageHandlersFactory } from './update-page';
@@ -89,9 +89,6 @@ const router = express.Router();
 module.exports = (crowi: Crowi) => {
 module.exports = (crowi: Crowi) => {
   const loginRequired = loginRequiredFactory(crowi, true);
   const loginRequired = loginRequiredFactory(crowi, true);
   const loginRequiredStrictly = loginRequiredFactory(crowi);
   const loginRequiredStrictly = loginRequiredFactory(crowi);
-  const certifySharedPage = require('../../../middlewares/certify-shared-page')(
-    crowi,
-  );
   const addActivity = generateAddActivityMiddleware();
   const addActivity = generateAddActivityMiddleware();
 
 
   const globalNotificationService = crowi.globalNotificationService;
   const globalNotificationService = crowi.globalNotificationService;
@@ -105,7 +102,6 @@ module.exports = (crowi: Crowi) => {
       query('pageId').isMongoId().optional().isString(),
       query('pageId').isMongoId().optional().isString(),
       query('path').optional().isString(),
       query('path').optional().isString(),
       query('findAll').optional().isBoolean(),
       query('findAll').optional().isBoolean(),
-      query('shareLinkId').optional().isMongoId(),
       query('includeEmpty').optional().isBoolean(),
       query('includeEmpty').optional().isBoolean(),
     ],
     ],
     likes: [body('pageId').isString(), body('bool').isBoolean()],
     likes: [body('pageId').isString(), body('bool').isBoolean()],
@@ -183,11 +179,6 @@ module.exports = (crowi: Crowi) => {
    *            description: Specific revision ID to retrieve
    *            description: Specific revision ID to retrieve
    *            schema:
    *            schema:
    *              $ref: '#/components/schemas/ObjectId'
    *              $ref: '#/components/schemas/ObjectId'
-   *          - name: shareLinkId
-   *            in: query
-   *            description: Share link ID for shared page access
-   *            schema:
-   *              $ref: '#/components/schemas/ObjectId'
    *          - name: includeEmpty
    *          - name: includeEmpty
    *            in: query
    *            in: query
    *            description: Include empty pages in results when using findAll
    *            description: Include empty pages in results when using findAll
@@ -204,110 +195,26 @@ module.exports = (crowi: Crowi) => {
   router.get(
   router.get(
     '/',
     '/',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    certifySharedPage,
     loginRequired,
     loginRequired,
     validator.getPage,
     validator.getPage,
     apiV3FormValidator,
     apiV3FormValidator,
     async (req, res) => {
     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(
       const disableUserPages = crowi.configManager.getConfig(
         'security:disableUserPages',
         '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) {
       if (!isValid) {
         return res.apiv3Err(
         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,
           400,
         );
         );
       }
       }
 
 
       try {
       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) {
         if (findAll != null) {
           const pages = await Page.findByPathAndViewer(
           const pages = await Page.findByPathAndViewer(
             path,
             path,
@@ -327,11 +234,13 @@ module.exports = (crowi: Crowi) => {
         }
         }
 
 
         return respondWithSinglePage(
         return respondWithSinglePage(
+          res,
           await findPageAndMetaDataByViewer(pageService, pageGrantService, {
           await findPageAndMetaDataByViewer(pageService, pageGrantService, {
             pageId,
             pageId,
             path,
             path,
             user,
             user,
           }),
           }),
+          { revisionId, disableUserPages },
         );
         );
       } catch (err) {
       } catch (err) {
         logger.error('get-page-failed', 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));
   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 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 { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
@@ -32,6 +32,7 @@ import {
   serializePageSecurely,
   serializePageSecurely,
   serializeRevisionSecurely,
   serializeRevisionSecurely,
 } from '~/server/models/serializers';
 } from '~/server/models/serializers';
+import { shouldGenerateUpdate } from '~/server/service/activity/update-activity-logic';
 import { configManager } from '~/server/service/config-manager/config-manager';
 import { configManager } from '~/server/service/config-manager/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 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);
       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
     // global notification
     try {
     try {
@@ -283,7 +309,6 @@ export const updatePageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
           409,
           409,
         );
         );
       }
       }
-
       let updatedPage: HydratedDocument<PageDocument>;
       let updatedPage: HydratedDocument<PageDocument>;
       let previousRevision: IRevisionHasId | null;
       let previousRevision: IRevisionHasId | null;
       try {
       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 { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { resolveLocalePath } from '../../util/safe-path-utils';
+
 const logger = loggerFactory('growi:routes:apiv3:user-activation');
 const logger = loggerFactory('growi:routes:apiv3:user-activation');
 
 
 const PASSOWRD_MINIMUM_NUMBER = 8;
 const PASSOWRD_MINIMUM_NUMBER = 8;
@@ -233,9 +235,10 @@ export const completeRegistrationAction = (crowi: Crowi) => {
               const admins = await User.findAdmins();
               const admins = await User.findAdmins();
               const appTitle = appService.getAppTitle();
               const appTitle = appService.getAppTitle();
               const locale = configManager.getConfig('app:globalLang');
               const locale = configManager.getConfig('app:globalLang');
-              const template = path.join(
+              const template = resolveLocalePath(
+                locale,
                 crowi.localeDir,
                 crowi.localeDir,
-                `${locale}/admin/userWaitingActivation.ejs`,
+                'admin/userWaitingActivation.ejs',
               );
               );
               const url = growiInfoService.getSiteUrl();
               const url = growiInfoService.getSiteUrl();
 
 
@@ -314,6 +317,12 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
   }
   }
 
 
   const locale = configManager.getConfig('app:globalLang');
   const locale = configManager.getConfig('app:globalLang');
+  const templatePath = resolveLocalePath(
+    locale,
+    localeDir,
+    'notifications/userActivation.ejs',
+  );
+
   const appUrl = growiInfoService.getSiteUrl();
   const appUrl = growiInfoService.getSiteUrl();
 
 
   const userRegistrationOrder =
   const userRegistrationOrder =
@@ -330,10 +339,7 @@ async function makeRegistrationEmailToken(email, crowi: Crowi) {
   return mailService.send({
   return mailService.send({
     to: email,
     to: email,
     subject: '[GROWI] User Activation',
     subject: '[GROWI] User Activation',
-    template: path.join(
-      localeDir,
-      `${locale}/notifications/userActivation.ejs`,
-    ),
+    template: templatePath,
     vars: {
     vars: {
       appTitle: appService.getAppTitle(),
       appTitle: appService.getAppTitle(),
       email,
       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 { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import express from 'express';
 import express from 'express';
 import { body, query } from 'express-validator';
 import { body, query } from 'express-validator';
-import path from 'pathe';
 import { isEmail } from 'validator';
 import { isEmail } from 'validator';
 
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 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 { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { resolveLocalePath } from '../../util/safe-path-utils';
 
 
 const logger = loggerFactory('growi:routes:apiv3:users');
 const logger = loggerFactory('growi:routes:apiv3:users');
 
 
@@ -206,6 +206,12 @@ module.exports = (crowi) => {
     const { appService, mailService } = crowi;
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
     const locale = configManager.getConfig('app:globalLang');
     const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      'admin/userInvitation.ejs',
+    );
+
     const failedToSendEmailList = [];
     const failedToSendEmailList = [];
 
 
     for (const user of userList) {
     for (const user of userList) {
@@ -214,10 +220,7 @@ module.exports = (crowi) => {
         await mailService.send({
         await mailService.send({
           to: user.email,
           to: user.email,
           subject: `Invitation to ${appTitle}`,
           subject: `Invitation to ${appTitle}`,
-          template: path.join(
-            crowi.localeDir,
-            `${locale}/admin/userInvitation.ejs`,
-          ),
+          template: templatePath,
           vars: {
           vars: {
             email: user.email,
             email: user.email,
             password: user.password,
             password: user.password,
@@ -242,14 +245,16 @@ module.exports = (crowi) => {
     const { appService, mailService } = crowi;
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
     const locale = configManager.getConfig('app:globalLang');
     const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      'admin/userResetPassword.ejs',
+    );
 
 
     await mailService.send({
     await mailService.send({
       to: user.email,
       to: user.email,
       subject: `New password for ${appTitle}`,
       subject: `New password for ${appTitle}`,
-      template: path.join(
-        crowi.localeDir,
-        `${locale}/admin/userResetPassword.ejs`,
-      ),
+      template: templatePath,
       vars: {
       vars: {
         email: user.email,
         email: user.email,
         password: user.password,
         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 { UserStatus } from '../models/user/conts';
 import { growiInfoService } from '../service/growi-info';
 import { growiInfoService } from '../service/growi-info';
+import { resolveLocalePath } from '../util/safe-path-utils';
 
 
 // disable all of linting
 // disable all of linting
 // because this file is a deprecated legacy of Crowi
 // 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 */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi, app) => {
 module.exports = (crowi, app) => {
   const logger = loggerFactory('growi:routes:login');
   const logger = loggerFactory('growi:routes:login');
-  const path = require('path');
   const { User } = crowi.models;
   const { User } = crowi.models;
   const { appService, aclService, mailService, activityService } = crowi;
   const { appService, aclService, mailService, activityService } = crowi;
   const activityEvent = crowi.events.activity;
   const activityEvent = crowi.events.activity;
@@ -23,15 +23,17 @@ module.exports = (crowi, app) => {
     const admins = await User.findAdmins();
     const admins = await User.findAdmins();
     const appTitle = appService.getAppTitle();
     const appTitle = appService.getAppTitle();
     const locale = configManager.getConfig('app:globalLang');
     const locale = configManager.getConfig('app:globalLang');
+    const templatePath = resolveLocalePath(
+      locale,
+      crowi.localeDir,
+      'admin/userWaitingActivation.ejs',
+    );
 
 
     const promises = admins.map((admin) => {
     const promises = admins.map((admin) => {
       return mailService.send({
       return mailService.send({
         to: admin.email,
         to: admin.email,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
         subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-        template: path.join(
-          crowi.localeDir,
-          `${locale}/admin/userWaitingActivation.ejs`,
-        ),
+        template: templatePath,
         vars: {
         vars: {
           adminUser: admin,
           adminUser: admin,
           createdUser: userData,
           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 { IUser } from '@growi/core/dist/interfaces';
 
 
 import type Crowi from '~/server/crowi';
 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 { growiInfoService } from '~/server/service/growi-info';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { resolveLocalePath } from '../../util/safe-path-utils';
 import type { GlobalNotificationEventVars } from './types';
 import type { GlobalNotificationEventVars } from './types';
 
 
 const _logger = loggerFactory('growi:service:GlobalNotificationMailService');
 const _logger = loggerFactory('growi:service:GlobalNotificationMailService');
@@ -89,17 +89,26 @@ class GlobalNotificationMailService {
     triggeredBy: IUser,
     triggeredBy: IUser,
     { comment, oldPath }: GlobalNotificationEventVars,
     { comment, oldPath }: GlobalNotificationEventVars,
   ): MailOption {
   ): MailOption {
-    const locale = configManager.getConfig('app:globalLang');
-    // validate for all events
     if (event == null || page == null || triggeredBy == null) {
     if (event == null || page == null || triggeredBy == null) {
       throw new Error(
       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,
       this.crowi.localeDir,
-      `${locale}/notifications/${event}.ejs`,
+      `notifications/${castedEvent}.ejs`,
     );
     );
 
 
     const path = page.path;
     const path = page.path;
@@ -115,7 +124,7 @@ class GlobalNotificationMailService {
       username: triggeredBy.username,
       username: triggeredBy.username,
     };
     };
 
 
-    switch (event) {
+    switch (castedEvent) {
       case GlobalNotificationSettingEvent.PAGE_CREATE:
       case GlobalNotificationSettingEvent.PAGE_CREATE:
         subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;
         break;

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

@@ -8,6 +8,7 @@ import path from 'path';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../crowi';
 import type Crowi from '../crowi';
+import { SUPPORTED_LOCALES } from '../util/safe-path-utils';
 import { configManager } from './config-manager';
 import { configManager } from './config-manager';
 
 
 const logger = loggerFactory('growi:service:installer');
 const logger = loggerFactory('growi:service:installer');
@@ -19,6 +20,11 @@ export type AutoInstallOptions = {
   serverDate?: Date;
   serverDate?: Date;
 };
 };
 
 
+const getSafeLang = (lang: Lang): Lang => {
+  if (SUPPORTED_LOCALES.includes(lang)) return lang;
+  return 'en_US';
+};
+
 export class InstallerService {
 export class InstallerService {
   crowi: Crowi;
   crowi: Crowi;
 
 
@@ -44,7 +50,12 @@ export class InstallerService {
     const { pageService } = this.crowi;
     const { pageService } = this.crowi;
 
 
     try {
     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(), {});
       return pageService.forceCreateBySystem(pagePath, markdown.toString(), {});
     } catch (err) {
     } catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
       logger.error(`Failed to create ${pagePath}`, err);
@@ -56,27 +67,33 @@ export class InstallerService {
     initialPagesCreatedAt?: Date,
     initialPagesCreatedAt?: Date,
   ): Promise<any> {
   ): Promise<any> {
     const { localeDir } = this.crowi;
     const { localeDir } = this.crowi;
+
+    const safeLang = getSafeLang(lang);
+
     // create /Sandbox/*
     // create /Sandbox/*
     /*
     /*
      * Keep in this order to
      * Keep in this order to
      *   1. avoid creating the same pages
      *   1. avoid creating the same pages
      *   2. avoid difference for order in VRT
      *   2. avoid difference for order in VRT
      */
      */
-    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox');
     await this.createPage(
     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',
       '/Sandbox/Markdown',
     );
     );
     await this.createPage(
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-bootstrap5.md'),
+      path.join(localeDir, safeLang, 'sandbox-bootstrap5.md'),
       '/Sandbox/Bootstrap5',
       '/Sandbox/Bootstrap5',
     );
     );
     await this.createPage(
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-diagrams.md'),
+      path.join(localeDir, safeLang, 'sandbox-diagrams.md'),
       '/Sandbox/Diagrams',
       '/Sandbox/Diagrams',
     );
     );
     await this.createPage(
     await this.createPage(
-      path.join(localeDir, lang, 'sandbox-math.md'),
+      path.join(localeDir, safeLang, 'sandbox-math.md'),
       '/Sandbox/Math',
       '/Sandbox/Math',
     );
     );
 
 
@@ -123,11 +140,13 @@ export class InstallerService {
     globalLang: Lang,
     globalLang: Lang,
     options?: AutoInstallOptions,
     options?: AutoInstallOptions,
   ): Promise<void> {
   ): Promise<void> {
+    const safeLang = getSafeLang(globalLang);
+
     await configManager.updateConfigs(
     await configManager.updateConfigs(
       {
       {
         'app:installed': true,
         'app:installed': true,
         'app:isV5Compatible': true,
         'app:isV5Compatible': true,
-        'app:globalLang': globalLang,
+        'app:globalLang': safeLang,
       },
       },
       { skipPubsub: true },
       { skipPubsub: true },
     );
     );
@@ -149,14 +168,15 @@ export class InstallerService {
     globalLang: Lang,
     globalLang: Lang,
     options?: AutoInstallOptions,
     options?: AutoInstallOptions,
   ): Promise<IUser> {
   ): Promise<IUser> {
-    await this.initDB(globalLang, options);
+    const safeLang = getSafeLang(globalLang);
 
 
+    await this.initDB(safeLang, options);
     const User = mongoose.model<IUser, { createUser }>('User');
     const User = mongoose.model<IUser, { createUser }>('User');
 
 
     // create portal page for '/' before creating admin user
     // create portal page for '/' before creating admin user
     try {
     try {
       await this.createPage(
       await this.createPage(
-        path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
+        path.join(this.crowi.localeDir, safeLang, 'welcome.md'),
         '/',
         '/',
       );
       );
     } catch (err) {
     } catch (err) {
@@ -172,12 +192,12 @@ export class InstallerService {
         username,
         username,
         email,
         email,
         password,
         password,
-        globalLang,
+        safeLang,
       );
       );
       await (adminUser as any).asyncGrantAdmin();
       await (adminUser as any).asyncGrantAdmin();
 
 
       // create initial pages
       // create initial pages
-      await this.createInitialPages(globalLang, options?.serverDate);
+      await this.createInitialPages(safeLang, options?.serverDate);
 
 
       return adminUser;
       return adminUser;
     } catch (err) {
     } 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';
 import path from 'pathe';
+export { AllLang as SUPPORTED_LOCALES };
 
 
 /**
 /**
  * Validates that the given file path is within the base directory.
  * 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.
  * Validates that joining baseDir with fileName results in a path within baseDir.
  * This is useful for validating user-provided file names before using them.
  * 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(() => {
     await waitFor(() => {
       expect(mockedApiv3Get).toHaveBeenCalledWith(
       expect(mockedApiv3Get).toHaveBeenCalledWith(
-        '/page',
+        '/page/shared',
         expect.objectContaining({
         expect.objectContaining({
           pageId,
           pageId,
           shareLinkId,
           shareLinkId,
@@ -1083,7 +1083,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
       );
       // path should NOT be sent
       // path should NOT be sent
       expect(mockedApiv3Get).toHaveBeenCalledWith(
       expect(mockedApiv3Get).toHaveBeenCalledWith(
-        '/page',
+        '/page/shared',
         expect.not.objectContaining({ path: expect.anything() }),
         expect.not.objectContaining({ path: expect.anything() }),
       );
       );
       expect(store.get(currentPageEntityIdAtom)).toBe(pageId);
       expect(store.get(currentPageEntityIdAtom)).toBe(pageId);
@@ -1109,10 +1109,10 @@ describe('useFetchCurrentPage - Integration Test', () => {
       await result.current.fetchCurrentPage({ path: '/some/path' });
       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(() => {
     await waitFor(() => {
       expect(mockedApiv3Get).toHaveBeenCalledWith(
       expect(mockedApiv3Get).toHaveBeenCalledWith(
-        '/page',
+        '/page/shared',
         expect.objectContaining({
         expect.objectContaining({
           path: '/some/path',
           path: '/some/path',
           shareLinkId,
           shareLinkId,

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

@@ -147,7 +147,7 @@ const buildApiParams = ({
   if (fetchPageArgs?.pageId != null) {
   if (fetchPageArgs?.pageId != null) {
     params.pageId = fetchPageArgs.pageId;
     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) {
   else if (shareLinkId != null && currentPageId != null) {
     params.pageId = currentPageId;
     params.pageId = currentPageId;
   } else if (decodedPathname != null) {
   } else if (decodedPathname != null) {
@@ -243,7 +243,10 @@ export const useFetchCurrentPage = (): {
         }
         }
 
 
         try {
         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;
           const { page: newData, meta } = data;
 
 
           set(currentPageDataAtom, newData ?? undefined);
           set(currentPageDataAtom, newData ?? undefined);

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

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

+ 14 - 2
package.json

@@ -90,13 +90,25 @@
   "// comments for pnpm.overrides": {
   "// comments for pnpm.overrides": {
     "@lykmapipo/common>flat": "flat v6 is provided only by ESM, but @lykmapipo/common requires CommonJS version",
     "@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>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": {
   "pnpm": {
     "overrides": {
     "overrides": {
       "@lykmapipo/common>flat": "5.0.2",
       "@lykmapipo/common>flat": "5.0.2",
       "@lykmapipo/common>mime": "3.0.0",
       "@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": [
     "ignoredBuiltDependencies": [
       "@swc/core",
       "@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 { KeyMapMode } from '../../../consts';
+import type { KeymapResult } from './types';
+
+export type { KeymapFactory, KeymapResult, ShortcutCategory } from './types';
 
 
 export const getKeymap = async (
 export const getKeymap = async (
   keyMapName?: KeyMapMode,
   keyMapName?: KeyMapMode,
   onSave?: () => void,
   onSave?: () => void,
-): Promise<Extension> => {
+): Promise<KeymapResult> => {
   switch (keyMapName) {
   switch (keyMapName) {
     case 'vim':
     case 'vim':
       return (await import('./vim')).vimKeymap(onSave);
       return (await import('./vim')).vimKeymap(onSave);
     case 'emacs':
     case 'emacs':
-      return (await import('@replit/codemirror-emacs')).emacs();
+      return (await import('./emacs')).emacsKeymap(onSave);
     case 'vscode':
     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) {
   if (onSave != null) {
     Vim.defineEx('write', 'w', onSave);
     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 { useCallback } from 'react';
 import type { EditorView } from '@codemirror/view';
 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 = (
 export const useInsertMarkdownElements = (
   view?: EditorView,
   view?: EditorView,
@@ -23,34 +11,7 @@ export const useInsertMarkdownElements = (
   return useCallback(
   return useCallback(
     (prefix, suffix) => {
     (prefix, suffix) => {
       if (view == null) return;
       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],
     [view],
   );
   );

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

@@ -1,175 +1,18 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-import type { ChangeSpec, Line, Text } from '@codemirror/state';
 import type { EditorView } from '@codemirror/view';
 import type { EditorView } from '@codemirror/view';
 
 
+import { insertLinePrefix } from '../../../services-internal/markdown-utils';
+
 export type InsertPrefix = (
 export type InsertPrefix = (
   prefix: string,
   prefix: string,
   noSpaceIfPrefixExists?: boolean,
   noSpaceIfPrefixExists?: boolean,
 ) => void;
 ) => 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 => {
 export const useInsertPrefix = (view?: EditorView): InsertPrefix => {
   return useCallback(
   return useCallback(
     (prefix: string, noSpaceIfPrefixExists = false) => {
     (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],
     [view],
   );
   );

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

@@ -17,6 +17,7 @@ import {
   insertNewRowToMarkdownTable,
   insertNewRowToMarkdownTable,
   isInTable,
   isInTable,
 } from '../services-internal';
 } from '../services-internal';
+import type { KeymapResult } from '../services-internal/keymaps';
 import { useEditorShortcuts } from './use-editor-shortcuts';
 import { useEditorShortcuts } from './use-editor-shortcuts';
 
 
 const useStyleActiveLine = (
 const useStyleActiveLine = (
@@ -88,8 +89,8 @@ const useKeymapExtension = (
   codeMirrorEditor?: UseCodeMirrorEditor,
   codeMirrorEditor?: UseCodeMirrorEditor,
   keymapMode?: KeyMapMode,
   keymapMode?: KeyMapMode,
   onSave?: () => void,
   onSave?: () => void,
-): void => {
-  const [keymapExtension, setKeymapExtension] = useState<Extension | undefined>(
+): KeymapResult | undefined => {
+  const [keymapResult, setKeymapResult] = useState<KeymapResult | undefined>(
     undefined,
     undefined,
   );
   );
 
 
@@ -104,20 +105,22 @@ const useKeymapExtension = (
     const settingKeyMap = async (name?: KeyMapMode) => {
     const settingKeyMap = async (name?: KeyMapMode) => {
       // Pass a stable wrapper function that delegates to the ref
       // Pass a stable wrapper function that delegates to the ref
       const stableOnSave = () => onSaveRef.current?.();
       const stableOnSave = () => onSaveRef.current?.();
-      setKeymapExtension(await getKeymap(name, stableOnSave));
+      setKeymapResult(await getKeymap(name, stableOnSave));
     };
     };
     settingKeyMap(keymapMode);
     settingKeyMap(keymapMode);
   }, [keymapMode]);
   }, [keymapMode]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (keymapExtension == null) {
+    if (keymapResult == null) {
       return;
       return;
     }
     }
     const cleanupFunction = codeMirrorEditor?.appendExtensions(
     const cleanupFunction = codeMirrorEditor?.appendExtensions(
-      Prec.low(keymapExtension),
+      keymapResult.precedence(keymapResult.extension),
     );
     );
     return cleanupFunction;
     return cleanupFunction;
-  }, [codeMirrorEditor, keymapExtension]);
+  }, [codeMirrorEditor, keymapResult]);
+
+  return keymapResult;
 };
 };
 
 
 export const useEditorSettings = (
 export const useEditorSettings = (
@@ -125,9 +128,13 @@ export const useEditorSettings = (
   editorSettings?: EditorSettings,
   editorSettings?: EditorSettings,
   onSave?: () => void,
   onSave?: () => void,
 ): void => {
 ): void => {
-  useEditorShortcuts(codeMirrorEditor, editorSettings?.keymapMode);
+  const keymapResult = useKeymapExtension(
+    codeMirrorEditor,
+    editorSettings?.keymapMode,
+    onSave,
+  );
+  useEditorShortcuts(codeMirrorEditor, keymapResult?.overrides);
   useStyleActiveLine(codeMirrorEditor, editorSettings?.styleActiveLine);
   useStyleActiveLine(codeMirrorEditor, editorSettings?.styleActiveLine);
   useEnterKeyHandler(codeMirrorEditor, editorSettings?.autoFormatMarkdownTable);
   useEnterKeyHandler(codeMirrorEditor, editorSettings?.autoFormatMarkdownTable);
   useThemeExtension(codeMirrorEditor, editorSettings?.theme);
   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 { EditorView } from '@codemirror/view';
 import { type KeyBinding, keymap } from '@codemirror/view';
 import { type KeyBinding, keymap } from '@codemirror/view';
 
 
-import type { KeyMapMode } from '../../consts';
 import type { UseCodeMirrorEditor } from '../services';
 import type { UseCodeMirrorEditor } from '../services';
 import { useAddMultiCursorKeyBindings } from '../services/use-codemirror-editor/utils/editor-shortcuts/add-multi-cursor';
 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';
 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 { 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 { 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 { 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 = (
 const useKeyBindings = (
   view?: EditorView,
   view?: EditorView,
-  keymapModeName?: KeyMapMode,
+  overrides?: readonly ShortcutCategory[],
 ): KeyBinding[] => {
 ): KeyBinding[] => {
-  const makeTextBoldKeyBinding = useMakeTextBoldKeyBinding(
-    view,
-    keymapModeName,
-  );
+  // Formatting keybindings
+  const makeTextBoldKeyBinding = useMakeTextBoldKeyBinding(view);
   const makeTextItalicKeyBinding = useMakeTextItalicKeyBinding(view);
   const makeTextItalicKeyBinding = useMakeTextItalicKeyBinding(view);
   const makeTextStrikethroughKeyBinding =
   const makeTextStrikethroughKeyBinding =
     useMakeTextStrikethroughKeyBinding(view);
     useMakeTextStrikethroughKeyBinding(view);
   const makeTextCodeCommand = useMakeTextCodeKeyBinding(view);
   const makeTextCodeCommand = useMakeTextCodeKeyBinding(view);
+
+  // Structural keybindings
   const insertNumberedKeyBinding = useInsertNumberedKeyBinding(view);
   const insertNumberedKeyBinding = useInsertNumberedKeyBinding(view);
   const insertBulletListKeyBinding = useInsertBulletListKeyBinding(view);
   const insertBulletListKeyBinding = useInsertBulletListKeyBinding(view);
   const insertBlockquoteKeyBinding = useInsertBlockquoteKeyBinding(view);
   const insertBlockquoteKeyBinding = useInsertBlockquoteKeyBinding(view);
-  const InsertLinkKeyBinding = useInsertLinkKeyBinding(view);
+  const insertLinkKeyBinding = useInsertLinkKeyBinding(view);
+
+  // Always-on keybindings
   const multiCursorKeyBindings = useAddMultiCursorKeyBindings();
   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 = (
 export const useEditorShortcuts = (
   codeMirrorEditor?: UseCodeMirrorEditor,
   codeMirrorEditor?: UseCodeMirrorEditor,
-  keymapModeName?: KeyMapMode,
+  overrides?: readonly ShortcutCategory[],
 ): void => {
 ): 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.
   // Since key combinations of 4 or more keys cannot be implemented with CodeMirror's keybinding, they are implemented as Extensions.
   const makeCodeBlockExtension = useMakeCodeBlockExtension();
   const makeCodeBlockExtension = useMakeCodeBlockExtension();

+ 1 - 1
packages/logger/package.json

@@ -16,7 +16,7 @@
   },
   },
   "scripts": {
   "scripts": {
     "build": "vite build",
     "build": "vite build",
-    "clean": "shx rm -rf dist",
+    "clean": "rimraf dist",
     "dev": "vite build --mode dev",
     "dev": "vite build --mode dev",
     "watch": "pnpm run dev -w --emptyOutDir=false",
     "watch": "pnpm run dev -w --emptyOutDir=false",
     "lint:biome": "biome check",
     "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"
     "build": "pnpm gen:client-code && tsc -p tsconfig.json"
   },
   },
   "dependencies": {
   "dependencies": {
-    "axios": "^1.11.0",
+    "axios": "^1.15.0",
     "tslib": "^2.8.0"
     "tslib": "^2.8.0"
   },
   },
   "devDependencies": {
   "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;
   const { rendererOptions, isDarkMode, disableSeparationByHeader } = options;
 
 
   if (
   if (
-    rendererOptions == null ||
     rendererOptions.remarkPlugins == null ||
     rendererOptions.remarkPlugins == null ||
     rendererOptions.components == 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([
   rendererOptions.remarkPlugins.push([

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

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

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

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

+ 1 - 1
packages/slack/package.json

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

+ 44 - 40
pnpm-lock.yaml

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