Browse Source

Merge branch 'dev/7.5.x'

Yuki Takei 2 weeks ago
parent
commit
42baad1d02
100 changed files with 3699 additions and 781 deletions
  1. 83 0
      .claude/commands/create-next-version-branch.md
  2. 32 2
      .claude/commands/kiro/spec-cleanup.md
  3. 31 2
      .claude/settings.json
  4. 1 1
      .claude/skills/tech-stack/SKILL.md
  5. 1 1
      .devcontainer/app/devcontainer.json
  6. 5 5
      .github/mergify.yml
  7. 16 33
      .github/workflows/ci-app-prod.yml
  8. 3 3
      .github/workflows/ci-app.yml
  9. 4 4
      .github/workflows/ci-pdf-converter.yml
  10. 4 4
      .github/workflows/ci-slackbot-proxy.yml
  11. 2 2
      .github/workflows/release-rc.yml
  12. 2 2
      .github/workflows/release-subpackages.yml
  13. 1 1
      .github/workflows/reusable-app-build-image.yml
  14. 22 27
      .github/workflows/reusable-app-prod.yml
  15. 1 0
      .gitignore
  16. 153 0
      .kiro/specs/hotkeys/design.md
  17. 101 0
      .kiro/specs/hotkeys/requirements.md
  18. 23 0
      .kiro/specs/hotkeys/spec.json
  19. 29 0
      .kiro/specs/hotkeys/tasks.md
  20. 233 0
      .kiro/specs/official-docker-image/design.md
  21. 82 0
      .kiro/specs/official-docker-image/requirements.md
  22. 288 0
      .kiro/specs/official-docker-image/research.md
  23. 22 0
      .kiro/specs/official-docker-image/spec.json
  24. 193 0
      .kiro/specs/official-docker-image/tasks.md
  25. 10 2
      .kiro/steering/structure.md
  26. 53 2
      .kiro/steering/tech.md
  27. 0 2
      .npmrc
  28. 1 2
      AGENTS.md
  29. 1 1
      README.md
  30. 1 1
      README_JP.md
  31. 74 0
      apps/app/.claude/rules/package-dependencies.md
  32. 16 2
      apps/app/.claude/skills/app-commands/SKILL.md
  33. 127 0
      apps/app/.claude/skills/build-optimization/SKILL.md
  34. 116 0
      apps/app/.claude/skills/vendor-styles-components/SKILL.md
  35. 2 0
      apps/app/.env.development
  36. 4 0
      apps/app/.gitignore
  37. 8 0
      apps/app/AGENTS.md
  38. 30 0
      apps/app/bin/assemble-prod.sh
  39. 36 0
      apps/app/bin/check-next-symlinks.sh
  40. 60 0
      apps/app/bin/measure-chunk-stats.sh
  41. 2 8
      apps/app/config/next-i18next.config.js
  42. 79 51
      apps/app/docker/Dockerfile
  43. 74 4
      apps/app/docker/Dockerfile.dockerignore
  44. 10 0
      apps/app/docker/README.md
  45. 2 0
      apps/app/docker/codebuild/buildspec.yml
  46. 0 18
      apps/app/docker/docker-entrypoint.sh
  47. 358 0
      apps/app/docker/docker-entrypoint.spec.ts
  48. 265 0
      apps/app/docker/docker-entrypoint.ts
  49. 0 5
      apps/app/next-env.d.ts
  50. 0 173
      apps/app/next.config.js
  51. 26 0
      apps/app/next.config.prod.cjs
  52. 147 0
      apps/app/next.config.ts
  53. 51 34
      apps/app/package.json
  54. 1 1
      apps/app/playwright.config.ts
  55. 5 3
      apps/app/src/client/components/Admin/AuditLog/DateRangePicker.tsx
  56. 6 0
      apps/app/src/client/components/Admin/AuditLog/DateRangePicker.vendor-styles.ts
  57. 3 3
      apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx
  58. 2 2
      apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx
  59. 1 1
      apps/app/src/client/components/Admin/Customize/ThemeColorBox.module.scss
  60. 3 5
      apps/app/src/client/components/Admin/UserGroup/UserGroupTable.module.scss
  61. 9 9
      apps/app/src/client/components/Admin/UserManagement.module.scss
  62. 3 3
      apps/app/src/client/components/Admin/Users/ExternalAccountTable.module.scss
  63. 1 1
      apps/app/src/client/components/Admin/Users/UserMenu.module.scss
  64. 4 4
      apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss
  65. 3 3
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.module.scss
  66. 20 20
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss
  67. 6 6
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.module.scss
  68. 3 3
      apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.module.scss
  69. 3 2
      apps/app/src/client/components/Common/ImageCropModal.tsx
  70. 6 0
      apps/app/src/client/components/Common/ImageCropModal.vendor-styles.ts
  71. 3 3
      apps/app/src/client/components/CompleteUserRegistrationForm.module.scss
  72. 4 4
      apps/app/src/client/components/CustomNavigation/CustomNav.module.scss
  73. 7 7
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.module.scss
  74. 3 2
      apps/app/src/client/components/FormattedDistanceDate.jsx
  75. 6 0
      apps/app/src/client/components/GrowiEditor.vendor-styles.ts
  76. 0 77
      apps/app/src/client/components/Hotkeys/HotkeysDetector.jsx
  77. 0 81
      apps/app/src/client/components/Hotkeys/HotkeysManager.jsx
  78. 100 0
      apps/app/src/client/components/Hotkeys/HotkeysManager.spec.tsx
  79. 99 0
      apps/app/src/client/components/Hotkeys/HotkeysManager.tsx
  80. 0 32
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx
  81. 40 0
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.spec.tsx
  82. 29 0
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.tsx
  83. 130 0
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.spec.tsx
  84. 8 5
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx
  85. 69 0
      apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.spec.tsx
  86. 15 10
      apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.tsx
  87. 61 0
      apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.spec.tsx
  88. 12 12
      apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx
  89. 0 30
      apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.jsx
  90. 39 0
      apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.spec.tsx
  91. 19 0
      apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.tsx
  92. 0 24
      apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx
  93. 33 0
      apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.spec.tsx
  94. 23 0
      apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.tsx
  95. 2 2
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.module.scss
  96. 1 2
      apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx
  97. 3 3
      apps/app/src/client/components/InstallerForm.module.scss
  98. 1 1
      apps/app/src/client/components/ItemsTree/ItemsTreeContentSkeleton.module.scss
  99. 16 16
      apps/app/src/client/components/LoginForm/LoginForm.module.scss
  100. 12 12
      apps/app/src/client/components/Me/AccessTokenScopeList.module.scss

+ 83 - 0
.claude/commands/create-next-version-branch.md

@@ -0,0 +1,83 @@
+---
+name: create-next-version-branch
+description: Create development and release branches with GitHub Release for the next version. Usage: /create-next-version-branch dev/{major}.{minor}.x
+---
+
+# Create Next Version Branch
+
+Automate the creation of development branches and GitHub Release for a new GROWI version.
+
+## Input
+
+The argument `$ARGUMENTS` must be a branch name in the format `dev/{major}.{minor}.x` (e.g., `dev/7.5.x`).
+
+## Procedure
+
+### Step 1: Parse and Validate Input
+
+1. Parse `$ARGUMENTS` to extract `{major}` and `{minor}` from the `dev/{major}.{minor}.x` pattern
+2. If the format is invalid, display an error and stop:
+   - Must match `dev/{number}.{number}.x`
+3. Set the following variables:
+   - `DEV_BRANCH`: `dev/{major}.{minor}.x`
+   - `RELEASE_BRANCH`: `release/{major}.{minor}.x`
+   - `TAG_NAME`: `v{major}.{minor}.x-base`
+   - `RELEASE_TITLE`: `v{major}.{minor}.x Base Release`
+
+### Step 2: Create and Push the Development Branch
+
+1. Confirm with the user before proceeding
+2. Create and push `DEV_BRANCH` from the current HEAD:
+   ```bash
+   git checkout -b {DEV_BRANCH}
+   git push origin {DEV_BRANCH}
+   ```
+
+### Step 3: Create GitHub Release
+
+1. Create a GitHub Release using `gh release create`:
+   ```bash
+   gh release create {TAG_NAME} \
+     --target {DEV_BRANCH} \
+     --title "{RELEASE_TITLE}" \
+     --notes "The base release for release-drafter to avoid \`Error: GraphQL Rate Limit Exceeded\`
+   https://github.com/release-drafter/release-drafter/issues/1018" \
+     --latest=false \
+     --prerelease=false
+   ```
+   - `--latest=false`: Do NOT set as latest release
+   - `--prerelease=false`: Do NOT set as pre-release
+
+### Step 4: Verify targetCommitish
+
+1. Run the following command and confirm that `targetCommitish` equals `DEV_BRANCH`:
+   ```bash
+   gh release view {TAG_NAME} --json targetCommitish
+   ```
+2. If `targetCommitish` does not match, display an error and stop
+
+### Step 5: Create and Push the Release Branch
+
+1. From the same commit (still on `DEV_BRANCH`), create and push `RELEASE_BRANCH`:
+   ```bash
+   git checkout -b {RELEASE_BRANCH}
+   git push origin {RELEASE_BRANCH}
+   ```
+
+### Step 6: Summary
+
+Display a summary of all created resources:
+
+```
+Created:
+  - Branch: {DEV_BRANCH} (pushed to origin)
+  - Branch: {RELEASE_BRANCH} (pushed to origin)
+  - GitHub Release: {RELEASE_TITLE} (tag: {TAG_NAME}, target: {DEV_BRANCH})
+```
+
+## Error Handling
+
+- If `DEV_BRANCH` already exists on the remote, warn the user and ask how to proceed
+- If `RELEASE_BRANCH` already exists on the remote, warn the user and ask how to proceed
+- If the tag `TAG_NAME` already exists, warn the user and ask how to proceed
+- If `gh` CLI is not authenticated, instruct the user to run `gh auth login`

+ 32 - 2
.claude/commands/kiro/spec-cleanup.md

@@ -41,6 +41,11 @@ Clean up and organize specification documents for feature **$1** after implement
 - Read all core files first
 - Read all core files first
 - Read other files to understand their content and value
 - Read other files to understand their content and value
 
 
+**Determine target language**:
+- Read `spec.json` and extract the `language` field (e.g., `"ja"`, `"en"`)
+- This is the language ALL spec document content must be written in
+- Note: code comments within code blocks are exempt (must stay in English per project rules)
+
 **Verify implementation status**:
 **Verify implementation status**:
 - Check that tasks are marked complete `[x]` in tasks.md
 - Check that tasks are marked complete `[x]` in tasks.md
 - If implementation incomplete, warn user and ask to confirm cleanup
 - If implementation incomplete, warn user and ask to confirm cleanup
@@ -86,6 +91,15 @@ Clean up and organize specification documents for feature **$1** after implement
      * Known limitations
      * Known limitations
    - Check if content from other files should be migrated here
    - Check if content from other files should be migrated here
 
 
+5. **Language audit** (compare actual language vs. `spec.json.language`):
+   - For each markdown file, scan prose content (headings, paragraphs, list items) and detect the written language
+   - Flag any file or section whose language does **not** match the target language
+   - Exemptions — do NOT flag:
+     * Content inside fenced code blocks (` ``` `) — code comments must stay in English
+     * Inline code spans (`` `...` ``)
+     * Proper nouns, technical terms, and identifiers that are always written in English
+   - Collect flagged items into a **translation plan**: file name, approximate line range, detected language, and a brief excerpt
+
 ### Step 3: Interactive Confirmation
 ### Step 3: Interactive Confirmation
 
 
 **Present cleanup plan to user**:
 **Present cleanup plan to user**:
@@ -110,6 +124,14 @@ For each file and section identified in Step 2, ask:
 - "design.md: Delete 'Security Considerations' section (lines X-Y)? [Y/n]"
 - "design.md: Delete 'Security Considerations' section (lines X-Y)? [Y/n]"
 - "design.md: Keep Architecture diagrams (essential for refactoring)? [Y/n]"
 - "design.md: Keep Architecture diagrams (essential for refactoring)? [Y/n]"
 
 
+**Translation confirmation** (if language mismatches were found in Step 2):
+- Show summary: "Found content in language(s) other than `{target_language}` in the following files:"
+  - List each flagged file with line range and a short excerpt
+- Ask: "Translate mismatched content to `{target_language}`? [Y/n]"
+  - If Y: translate all flagged sections in Step 4
+  - If n: skip translation (leave files as-is)
+- Note: code blocks are never translated
+
 **Batch similar decisions**:
 **Batch similar decisions**:
 - Group related sections (e.g., all "delete implementation details" decisions)
 - Group related sections (e.g., all "delete implementation details" decisions)
 - Allow user to approve categories rather than individual items
 - Allow user to approve categories rather than individual items
@@ -153,7 +175,14 @@ For each file and section identified in Step 2, ask:
    - Preserve architecture diagrams and component interfaces
    - Preserve architecture diagrams and component interfaces
    - Keep design decisions and rationale sections
    - Keep design decisions and rationale sections
 
 
-5. **Update spec.json metadata**:
+5. **Translate language-mismatched content** (if approved):
+   - For each flagged file and section, translate prose content to the target language
+   - **Never translate**: content inside fenced code blocks or inline code spans
+   - Preserve all Markdown formatting (headings, bold, lists, links, etc.)
+   - After translation, verify the overall document reads naturally in the target language
+   - Document translated files in the cleanup summary
+
+6. **Update spec.json metadata**:
    - Set `phase: "implementation-complete"` (if not already set)
    - Set `phase: "implementation-complete"` (if not already set)
    - Add `cleanup_completed: true` flag
    - Add `cleanup_completed: true` flag
    - Update `updated_at` timestamp
    - Update `updated_at` timestamp
@@ -176,6 +205,7 @@ For each file and section identified in Step 2, ask:
 - ✅ research.md: Added Session 2 discoveries + salvaged content (180 lines added)
 - ✅ research.md: Added Session 2 discoveries + salvaged content (180 lines added)
 - ✅ requirements.md: Simplified 6 requirements (350 lines → 180 lines)
 - ✅ requirements.md: Simplified 6 requirements (350 lines → 180 lines)
 - ✅ design.md: Removed 4 sections, added constraints + salvaged content (250 lines removed, 100 added)
 - ✅ design.md: Removed 4 sections, added constraints + salvaged content (250 lines removed, 100 added)
+- ✅ requirements.md: Translated mismatched sections to {target_language}
 
 
 ### Information Salvaged
 ### Information Salvaged
 - Implementation discoveries from validation-report.md → research.md
 - Implementation discoveries from validation-report.md → research.md
@@ -196,7 +226,7 @@ For each file and section identified in Step 2, ask:
 ## Critical Constraints
 ## Critical Constraints
 
 
 - **User approval required**: Never delete content without explicit confirmation
 - **User approval required**: Never delete content without explicit confirmation
-- **Language consistency**: Use language specified in spec.json for all updates
+- **Language consistency**: All prose content must be written in the language specified in `spec.json.language`; translate any mismatched sections (code blocks exempt)
 - **Preserve history**: Don't delete discovery rationale or design decisions
 - **Preserve history**: Don't delete discovery rationale or design decisions
 - **Balance brevity with completeness**: Remove redundancy but keep essential context
 - **Balance brevity with completeness**: Remove redundancy but keep essential context
 - **Interactive workflow**: Pause for user input rather than making assumptions
 - **Interactive workflow**: Pause for user input rather than making assumptions

+ 31 - 2
.claude/settings.json

@@ -1,4 +1,34 @@
 {
 {
+  "permissions": {
+    "allow": [
+      "Bash(node --version)",
+      "Bash(npm --version)",
+      "Bash(npm view *)",
+      "Bash(pnpm --version)",
+      "Bash(turbo --version)",
+      "Bash(turbo run build)",
+      "Bash(turbo run lint)",
+      "Bash(pnpm run lint:*)",
+      "Bash(pnpm vitest run *)",
+      "Bash(pnpm biome check *)",
+      "Bash(cat *)",
+      "Bash(echo *)",
+      "Bash(find *)",
+      "Bash(grep *)",
+      "Bash(git diff *)",
+      "Bash(gh issue view *)",
+      "Bash(gh pr view *)",
+      "Bash(gh pr diff *)",
+      "Bash(ls *)",
+      "WebFetch(domain:github.com)",
+      "mcp__context7__*",
+      "mcp__plugin_context7_*",
+      "mcp__github__*",
+      "WebSearch",
+      "WebFetch"
+    ]
+  },
+  "enableAllProjectMcpServers": true,
   "hooks": {
   "hooks": {
     "SessionStart": [
     "SessionStart": [
       {
       {
@@ -17,8 +47,7 @@
           {
           {
             "type": "command",
             "type": "command",
             "command": "if [[ \"$FILE\" == */apps/* ]] || [[ \"$FILE\" == */packages/* ]]; then REPO_ROOT=$(echo \"$FILE\" | sed 's|/\\(apps\\|packages\\)/.*|/|'); cd \"$REPO_ROOT\" && pnpm biome check --write \"$FILE\" 2>/dev/null || true; fi",
             "command": "if [[ \"$FILE\" == */apps/* ]] || [[ \"$FILE\" == */packages/* ]]; then REPO_ROOT=$(echo \"$FILE\" | sed 's|/\\(apps\\|packages\\)/.*|/|'); cd \"$REPO_ROOT\" && pnpm biome check --write \"$FILE\" 2>/dev/null || true; fi",
-            "timeout": 30,
-            "description": "Auto-format edited files in apps/* and packages/* with Biome"
+            "timeout": 30
           }
           }
         ]
         ]
       }
       }

+ 1 - 1
.claude/skills/tech-stack/SKILL.md

@@ -38,7 +38,7 @@ user-invocable: false
 ## Build & Development Tools
 ## Build & Development Tools
 
 
 ### Package Management
 ### Package Management
-- **pnpm** 10.4.1 - Package manager (faster, more efficient than npm/yarn)
+- **pnpm** Package manager (faster, more efficient than npm/yarn)
 
 
 ### Monorepo Orchestration
 ### Monorepo Orchestration
 - **Turborepo** ^2.1.3 - Build system with caching and parallelization
 - **Turborepo** ^2.1.3 - Build system with caching and parallelization

+ 1 - 1
.devcontainer/app/devcontainer.json

@@ -8,7 +8,7 @@
 
 
   "features": {
   "features": {
     "ghcr.io/devcontainers/features/node:1": {
     "ghcr.io/devcontainers/features/node:1": {
-      "version": "20.18.3"
+      "version": "24.14.0"
     },
     },
     "ghcr.io/devcontainers/features/github-cli:1": {}
     "ghcr.io/devcontainers/features/github-cli:1": {}
   },
   },

+ 5 - 5
.github/mergify.yml

@@ -6,17 +6,17 @@ queue_rules:
       - check-success ~= ci-app-launch-dev
       - check-success ~= ci-app-launch-dev
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node20 /
+      - -check-failure ~= test-prod-node24 /
     merge_conditions:
     merge_conditions:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test
       - check-success ~= ci-app-test
       - check-success ~= ci-app-launch-dev
       - check-success ~= ci-app-launch-dev
-      - check-success = test-prod-node20 / build-prod
-      - check-success ~= test-prod-node20 / launch-prod
-      - check-success ~= test-prod-node20 / run-playwright
+      - check-success = test-prod-node24 / build-prod
+      - check-success ~= test-prod-node24 / launch-prod
+      - check-success ~= test-prod-node24 / run-playwright
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node20 /
+      - -check-failure ~= test-prod-node24 /
 
 
 pull_request_rules:
 pull_request_rules:
   - name: Automatic queue to merge
   - name: Automatic queue to merge

+ 16 - 33
.github/workflows/ci-app-prod.yml

@@ -39,23 +39,22 @@ concurrency:
 
 
 jobs:
 jobs:
 
 
-  test-prod-node18:
-    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
-    if: |
-      ( github.event_name == 'push'
-        || github.base_ref == 'master'
-        || github.base_ref == 'dev/7.*.x'
-        || startsWith( github.base_ref, 'release/' )
-        || startsWith( github.head_ref, 'mergify/merge-queue/' ))
-    with:
-      node-version: 18.x
-      skip-e2e-test: true
-    secrets:
-      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
-
+  # test-prod-node22:
+  #   uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@dev/7.5.x
+  #   if: |
+  #     ( github.event_name == 'push'
+  #       || github.base_ref == 'master'
+  #       || github.base_ref == 'dev/7.*.x'
+  #       || startsWith( github.base_ref, 'release/' )
+  #       || startsWith( github.head_ref, 'mergify/merge-queue/' ))
+  #   with:
+  #     node-version: 22.x
+  #     skip-e2e-test: true
+  #   secrets:
+  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
-  test-prod-node20:
-    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
+  test-prod-node24:
+    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@dev/7.5.x
     if: |
     if: |
       ( github.event_name == 'push'
       ( github.event_name == 'push'
         || github.base_ref == 'master'
         || github.base_ref == 'master'
@@ -63,23 +62,7 @@ jobs:
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
     with:
-      node-version: 20.x
+      node-version: 24.x
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
     secrets:
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-  # run-reg-suit-node20:
-  #   needs: [test-prod-node20]
-
-  #   uses: growilabs/growi/.github/workflows/reusable-app-reg-suit.yml@master
-
-  #   if: always()
-
-  #   with:
-  #     node-version: 20.x
-  #     skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
-  #   secrets:
-  #     REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
-  #     AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
-  #     AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 3 - 3
.github/workflows/ci-app.yml

@@ -44,7 +44,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
 
     steps:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
@@ -92,7 +92,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
         mongodb-version: ['6.0', '8.0']
         mongodb-version: ['6.0', '8.0']
 
 
     services:
     services:
@@ -157,7 +157,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
         mongodb-version: ['6.0', '8.0']
         mongodb-version: ['6.0', '8.0']
 
 
     services:
     services:

+ 4 - 4
.github/workflows/ci-pdf-converter.yml

@@ -29,7 +29,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -65,7 +65,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -104,7 +104,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -142,7 +142,7 @@ jobs:
     - name: Assembling all dependencies
     - name: Assembling all dependencies
       run: |
       run: |
         rm -rf out
         rm -rf out
-        pnpm deploy out --prod --filter @growi/pdf-converter
+        pnpm deploy out --prod --legacy --filter @growi/pdf-converter
         rm -rf apps/pdf-converter/node_modules && mv out/node_modules apps/pdf-converter/node_modules
         rm -rf apps/pdf-converter/node_modules && mv out/node_modules apps/pdf-converter/node_modules
 
 
     - name: pnpm run start:prod:ci
     - name: pnpm run start:prod:ci

+ 4 - 4
.github/workflows/ci-slackbot-proxy.yml

@@ -30,7 +30,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -85,7 +85,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
 
     services:
     services:
       mysql:
       mysql:
@@ -163,7 +163,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
 
     services:
     services:
       mysql:
       mysql:
@@ -211,7 +211,7 @@ jobs:
     - name: Assembling all dependencies
     - name: Assembling all dependencies
       run: |
       run: |
         rm -rf out
         rm -rf out
-        pnpm deploy out --prod --filter @growi/slackbot-proxy
+        pnpm deploy out --prod --legacy --filter @growi/slackbot-proxy
         rm -rf apps/slackbot-proxy/node_modules && mv out/node_modules apps/slackbot-proxy/node_modules
         rm -rf apps/slackbot-proxy/node_modules && mv out/node_modules apps/slackbot-proxy/node_modules
 
 
     - name: pnpm run start:prod:ci
     - name: pnpm run start:prod:ci

+ 2 - 2
.github/workflows/release-rc.yml

@@ -37,7 +37,7 @@ jobs:
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
 
   build-image-rc:
   build-image-rc:
-    uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@rc/v7.5.x-node24
     with:
     with:
       image-name: growilabs/growi
       image-name: growilabs/growi
       tag-temporary: latest-rc
       tag-temporary: latest-rc
@@ -47,7 +47,7 @@ jobs:
   publish-image-rc:
   publish-image-rc:
     needs: [determine-tags, build-image-rc]
     needs: [determine-tags, build-image-rc]
 
 
-    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@rc/v7.5.x-node24
     with:
     with:
       tags: ${{ needs.determine-tags.outputs.TAGS }}
       tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io
       registry: docker.io

+ 2 - 2
.github/workflows/release-subpackages.yml

@@ -32,7 +32,7 @@ jobs:
 
 
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
@@ -75,7 +75,7 @@ jobs:
 
 
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -48,7 +48,7 @@ jobs:
         projectName: growi-official-image-builder
         projectName: growi-official-image-builder
       env:
       env:
         CODEBUILD__sourceVersion: ${{ inputs.source-version }}
         CODEBUILD__sourceVersion: ${{ inputs.source-version }}
-        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/amazonlinux2-x86_64-standard:4.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:2.0' }}
+        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/amazonlinux2-x86_64-standard:5.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:3.0' }}
         CODEBUILD__environmentTypeOverride: ${{ (matrix.platform == 'amd64' && 'LINUX_CONTAINER') || 'ARM_CONTAINER' }}
         CODEBUILD__environmentTypeOverride: ${{ (matrix.platform == 'amd64' && 'LINUX_CONTAINER') || 'ARM_CONTAINER' }}
         CODEBUILD__environmentVariablesOverride: '[
         CODEBUILD__environmentVariablesOverride: '[
           { "name": "IMAGE_TAG", "type": "PLAINTEXT", "value": "docker.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" }
           { "name": "IMAGE_TAG", "type": "PLAINTEXT", "value": "docker.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" }

+ 22 - 27
.github/workflows/reusable-app-prod.yml

@@ -16,7 +16,7 @@ on:
       node-version:
       node-version:
         required: true
         required: true
         type: string
         type: string
-        default: 22.x
+        default: 24.x
       skip-e2e-test:
       skip-e2e-test:
         type: boolean
         type: boolean
         default: false
         default: false
@@ -57,17 +57,18 @@ jobs:
       env:
       env:
         ANALYZE: 1
         ANALYZE: 1
 
 
-    - name: Assembling all dependencies
-      run: |
-        rm -rf out
-        pnpm deploy out --prod --filter @growi/app
-        rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
+    - name: Assemble production artifacts
+      run: bash apps/app/bin/assemble-prod.sh
+
+    - name: Check for broken symlinks in .next/node_modules
+      run: bash apps/app/bin/check-next-symlinks.sh
 
 
     - name: Archive production files
     - name: Archive production files
       id: archive-prod-files
       id: archive-prod-files
       run: |
       run: |
         tar -zcf production.tar.gz --exclude ./apps/app/.next/cache \
         tar -zcf production.tar.gz --exclude ./apps/app/.next/cache \
           package.json \
           package.json \
+          node_modules \
           apps/app/.next \
           apps/app/.next \
           apps/app/config \
           apps/app/config \
           apps/app/dist \
           apps/app/dist \
@@ -76,6 +77,7 @@ jobs:
           apps/app/tmp \
           apps/app/tmp \
           apps/app/.env.production* \
           apps/app/.env.production* \
           apps/app/node_modules \
           apps/app/node_modules \
+          apps/app/next.config.js \
           apps/app/package.json
           apps/app/package.json
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
 
 
@@ -124,30 +126,22 @@ jobs:
           discovery.type: single-node
           discovery.type: single-node
 
 
     steps:
     steps:
-    - uses: actions/checkout@v4
-
-    - uses: pnpm/action-setup@v4
-
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
-        cache: 'pnpm'
-
-    # avoid setup-node cache failure; see: https://github.com/actions/setup-node/issues/1137
-    - name: Verify PNPM Cache Directory
-      run: |
-        PNPM_STORE_PATH="$( pnpm store path --silent )"
-        [ -d "$PNPM_STORE_PATH" ] || mkdir -vp "$PNPM_STORE_PATH"
 
 
     - name: Download production files artifact
     - name: Download production files artifact
       uses: actions/download-artifact@v4
       uses: actions/download-artifact@v4
       with:
       with:
         name: Production Files (node${{ inputs.node-version }})
         name: Production Files (node${{ inputs.node-version }})
 
 
-    - name: Extract procution files
+    - name: Extract production files
       run: |
       run: |
         tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
         tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
 
 
+    # Run after extraction so pnpm/action-setup@v4 can read packageManager from package.json
+    - uses: pnpm/action-setup@v4
+
     - name: pnpm run server:ci
     - name: pnpm run server:ci
       working-directory: ./apps/app
       working-directory: ./apps/app
       run: |
       run: |
@@ -179,7 +173,7 @@ jobs:
     container:
     container:
       # Match the Playwright version
       # Match the Playwright version
       # https://github.com/microsoft/playwright/issues/20010
       # https://github.com/microsoft/playwright/issues/20010
-      image: mcr.microsoft.com/playwright:v1.49.1-jammy
+      image: mcr.microsoft.com/playwright:v1.58.2-jammy
 
 
     strategy:
     strategy:
       fail-fast: false
       fail-fast: false
@@ -223,14 +217,14 @@ jobs:
       with:
       with:
         name: Production Files (node${{ inputs.node-version }})
         name: Production Files (node${{ inputs.node-version }})
 
 
-    - name: Extract procution files
+    - name: Extract production files to isolated directory
       run: |
       run: |
-        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+        mkdir -p /tmp/growi-prod
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }} -C /tmp/growi-prod
 
 
     - name: Copy dotenv file for ci
     - name: Copy dotenv file for ci
-      working-directory: ./apps/app
       run: |
       run: |
-        cat config/ci/.env.local.for-ci >> .env.production.local
+        cat apps/app/config/ci/.env.local.for-ci >> /tmp/growi-prod/apps/app/.env.production.local
 
 
     - name: Playwright Run (--project=chromium/installer)
     - name: Playwright Run (--project=chromium/installer)
       if: ${{ matrix.browser == 'chromium' }}
       if: ${{ matrix.browser == 'chromium' }}
@@ -240,13 +234,13 @@ jobs:
       env:
       env:
         DEBUG: pw:api
         DEBUG: pw:api
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server'
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-installer
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-installer
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
 
     - name: Copy dotenv file for automatic installation
     - name: Copy dotenv file for automatic installation
-      working-directory: ./apps/app
       run: |
       run: |
-        cat config/ci/.env.local.for-auto-install >> .env.production.local
+        cat apps/app/config/ci/.env.local.for-auto-install >> /tmp/growi-prod/apps/app/.env.production.local
 
 
     - name: Playwright Run
     - name: Playwright Run
       working-directory: ./apps/app
       working-directory: ./apps/app
@@ -255,13 +249,13 @@ jobs:
       env:
       env:
         DEBUG: pw:api
         DEBUG: pw:api
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server'
         MONGO_URI: mongodb://mongodb:27017/growi-playwright
         MONGO_URI: mongodb://mongodb:27017/growi-playwright
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
 
     - name: Copy dotenv file for automatic installation with allowing guest mode
     - name: Copy dotenv file for automatic installation with allowing guest mode
-      working-directory: ./apps/app
       run: |
       run: |
-        cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
+        cat apps/app/config/ci/.env.local.for-auto-install-with-allowing-guest >> /tmp/growi-prod/apps/app/.env.production.local
 
 
     - name: Playwright Run (--project=${browser}/guest-mode)
     - name: Playwright Run (--project=${browser}/guest-mode)
       working-directory: ./apps/app
       working-directory: ./apps/app
@@ -270,6 +264,7 @@ jobs:
       env:
       env:
         DEBUG: pw:api
         DEBUG: pw:api
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server'
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-guest-mode
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-guest-mode
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
 

+ 1 - 0
.gitignore

@@ -2,6 +2,7 @@
 
 
 # dependencies
 # dependencies
 node_modules
 node_modules
+node_modules.*
 /.pnp
 /.pnp
 .pnp.js
 .pnp.js
 .pnpm-store
 .pnpm-store

+ 153 - 0
.kiro/specs/hotkeys/design.md

@@ -0,0 +1,153 @@
+# Technical Design
+
+## Architecture Overview
+
+The GROWI hotkey system manages keyboard shortcuts globally. It uses `tinykeys` (~400 byte) as the key binding engine and a **subscriber component pattern** to execute actions when hotkeys fire.
+
+### Component Diagram
+
+```
+BasicLayout / AdminLayout
+  └─ HotkeysManager (loaded via next/dynamic, ssr: false)
+       ├─ tinykeys(window, bindings) — registers all key bindings
+       └─ renders subscriber components on demand:
+            ├─ EditPage
+            ├─ CreatePage
+            ├─ FocusToGlobalSearch
+            ├─ ShowShortcutsModal
+            ├─ ShowStaffCredit
+            └─ SwitchToMirrorMode
+```
+
+### Key Files
+
+| File | Role |
+|------|------|
+| `src/client/components/Hotkeys/HotkeysManager.tsx` | Core orchestrator — binds all keys via tinykeys, renders subscribers |
+| `src/client/components/Hotkeys/Subscribers/*.tsx` | Individual action handlers rendered when their hotkey fires |
+| `src/components/Layout/BasicLayout.tsx` | Mounts HotkeysManager via `next/dynamic({ ssr: false })` |
+| `src/components/Layout/AdminLayout.tsx` | Mounts HotkeysManager via `next/dynamic({ ssr: false })` |
+
+## Design Decisions
+
+### D1: tinykeys as Binding Engine
+
+**Decision**: Use `tinykeys` (v3) instead of `react-hotkeys` (v2).
+
+**Rationale**:
+- `react-hotkeys` contributes 91 modules to async chunks; `tinykeys` is 1 module (~400 bytes)
+- tinykeys natively supports single keys, modifier combos (`Control+/`), and multi-key sequences (`ArrowUp ArrowUp ...`)
+- No need for custom state machine (`HotkeyStroke`) or detection wrapper (`HotkeysDetector`)
+
+**Trade-off**: tinykeys has no React integration — key binding is done imperatively in a `useEffect` hook rather than declaratively via JSX props. This is acceptable given the simplicity of the binding map.
+
+### D2: Subscriber-Owned Binding Definitions
+
+**Decision**: Each subscriber component exports its own `hotkeyBindings` metadata alongside its React component. `HotkeysManager` imports these definitions and auto-builds the tinykeys binding map — it never hardcodes specific keys or subscriber references.
+
+**Rationale**:
+- True "1 module = 1 hotkey" encapsulation: each subscriber owns its key binding, handler category, and action logic
+- Adding a new hotkey requires creating only one file (the new subscriber); `HotkeysManager` needs no modification
+- Fully satisfies Req 7 AC 2 ("define hotkey without modifying core detection logic")
+- Self-documenting: looking at a subscriber file tells you everything about that hotkey
+
+**Type contract**:
+```typescript
+// Shared type definition in HotkeysManager.tsx or a shared types file
+type HotkeyCategory = 'single' | 'modifier';
+
+type HotkeyBindingDef = {
+  keys: string | string[];   // tinykeys key expression(s)
+  category: HotkeyCategory;  // determines handler wrapper (single = input guard, modifier = no guard)
+};
+
+type HotkeySubscriber = {
+  component: React.ComponentType<{ onDeleteRender: () => void }>;
+  bindings: HotkeyBindingDef;
+};
+```
+
+**Subscriber example**:
+```typescript
+// CreatePage.tsx
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'c',
+  category: 'single',
+};
+
+export const CreatePage = ({ onDeleteRender }: Props): null => { /* ... */ };
+```
+
+```typescript
+// ShowShortcutsModal.tsx
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: ['Control+/', 'Meta+/'],
+  category: 'modifier',
+};
+```
+
+**HotkeysManager usage**:
+```typescript
+// HotkeysManager.tsx
+import * as createPage from './Subscribers/CreatePage';
+import * as editPage from './Subscribers/EditPage';
+// ... other subscribers
+
+const subscribers: HotkeySubscriber[] = [
+  { component: createPage.CreatePage, bindings: createPage.hotkeyBindings },
+  { component: editPage.EditPage, bindings: editPage.hotkeyBindings },
+  // ...
+];
+
+// In useEffect: iterate subscribers to build tinykeys binding map
+```
+
+**Trade-off**: Slightly more structure than a plain object literal, but the pattern is minimal and each subscriber file is fully self-contained.
+
+### D3: Subscriber Render-on-Fire Pattern
+
+**Decision**: Subscriber components are rendered into the React tree only when their hotkey fires, and self-remove after executing their action.
+
+**Rationale**:
+- Preserves the existing GROWI pattern where hotkey actions need access to React hooks (Jotai atoms, SWR, i18n, routing)
+- Components call `onDeleteRender()` after completing their effect to clean up
+- Uses a monotonically incrementing key ref to avoid React key collisions
+
+### D4: Two Handler Categories
+
+**Decision**: `singleKeyHandler` and `modifierKeyHandler` are separated.
+
+**Rationale**:
+- Single-key shortcuts (`e`, `c`, `/`) must be suppressed when the user is typing in input/textarea/contenteditable elements
+- Modifier-key shortcuts (`Control+/`, `Meta+/`) and multi-key sequences should fire regardless of focus, as they are unlikely to conflict with text entry
+- `isEditableTarget()` check is applied only to single-key handlers
+
+### D5: Client-Only Loading
+
+**Decision**: HotkeysManager is loaded via `next/dynamic({ ssr: false })`.
+
+**Rationale**:
+- Keyboard events are client-only; no SSR rendering is needed
+- Dynamic import keeps hotkey modules out of initial server-rendered chunks
+- Both BasicLayout and AdminLayout follow this pattern
+
+## Implementation Deviations from Requirements
+
+| Requirement | Deviation | Justification |
+|-------------|-----------|---------------|
+| Req 8 AC 2: "export typed interfaces for hotkey definitions" | `HotkeyBindingDef` and `HotkeySubscriber` types are exported for subscriber use but not published as a package API | These types are internal to the Hotkeys module; no external consumers need them |
+
+> **Note (task 5)**: Req 8 AC 1 is now fully satisfied — all 6 subscriber components converted from `.jsx` to `.tsx` with TypeScript `Props` types and named exports.
+> **Note (D2 revision)**: Req 7 AC 2 is now fully satisfied — subscriber-owned binding definitions mean adding a hotkey requires only creating a new subscriber file.
+
+## Key Binding Format (tinykeys)
+
+| Category | Format | Example |
+|----------|--------|---------|
+| Single key | `"key"` | `e`, `c`, `"/"` |
+| Modifier combo | `"Modifier+key"` | `"Control+/"`, `"Meta+/"` |
+| Multi-key sequence | `"key1 key2 key3 ..."` (space-separated) | `"ArrowUp ArrowUp ArrowDown ArrowDown ..."` |
+| Platform modifier | `"$mod+key"` | `"$mod+/"` (Control on Windows/Linux, Meta on macOS) |
+
+> Note: The current implementation uses explicit `Control+/` and `Meta+/` rather than `$mod+/` to match the original behavior.
+

+ 101 - 0
.kiro/specs/hotkeys/requirements.md

@@ -0,0 +1,101 @@
+# Requirements Document
+
+## Introduction
+
+GROWI currently uses `react-hotkeys` (v2.0.0, 91 modules in async chunk) to manage keyboard shortcuts via a custom subscriber pattern. The library is identified as an optimization target due to its module footprint. This specification covers the migration from `react-hotkeys` to `tinykeys`, a lightweight (~400B) keyboard shortcut library, while preserving all existing hotkey functionality and the subscriber-based architecture.
+
+### Current Architecture Overview
+
+- **HotkeysDetector**: Wraps `react-hotkeys`'s `GlobalHotKeys` to capture key events and convert them to custom key expressions
+- **HotkeyStroke**: State machine model for multi-key sequence detection (e.g., Konami codes)
+- **HotkeysManager**: Orchestrator that maps strokes to subscriber components and manages their lifecycle
+- **Subscribers**: 6 components (CreatePage, EditPage, FocusToGlobalSearch, ShowShortcutsModal, ShowStaffCredit, SwitchToMirrorMode) that self-define hotkeys via static `getHotkeyStrokes()`
+
+### Registered Hotkeys
+
+| Shortcut | Action |
+|----------|--------|
+| `c` | Open page creation modal |
+| `e` | Start page editing |
+| `/` | Focus global search |
+| `Ctrl+/` or `Meta+/` | Open shortcuts help modal |
+| `↑↑↓↓←→←→BA` | Show staff credits (Konami code) |
+| `XXBBAAYYA↓←` | Switch to mirror mode (Konami code) |
+
+## Requirements
+
+### Requirement 1: Replace react-hotkeys Dependency with tinykeys
+
+**Objective:** As a developer, I want to replace `react-hotkeys` with `tinykeys`, so that the application's async chunk module count is reduced and the hotkey system uses a modern, lightweight library.
+
+#### Acceptance Criteria
+
+1. The GROWI application shall use `tinykeys` as the keyboard shortcut library instead of `react-hotkeys`.
+2. When the migration is complete, the `react-hotkeys` package shall be removed from `package.json` dependencies.
+3. The GROWI application shall not increase the total async chunk module count compared to the current `react-hotkeys` implementation.
+
+### Requirement 2: Preserve Single-Key Shortcut Functionality
+
+**Objective:** As a user, I want single-key shortcuts to continue working after the migration, so that my workflow is not disrupted.
+
+#### Acceptance Criteria
+
+1. When the user presses the `c` key (outside an input/textarea/editable element), the Hotkeys system shall open the page creation modal.
+2. When the user presses the `e` key (outside an input/textarea/editable element), the Hotkeys system shall start page editing if the page is editable and no modal is open.
+3. When the user presses the `/` key (outside an input/textarea/editable element), the Hotkeys system shall open the global search modal.
+
+### Requirement 3: Preserve Modifier-Key Shortcut Functionality
+
+**Objective:** As a user, I want modifier-key shortcuts to continue working after the migration, so that keyboard shortcut help remains accessible.
+
+#### Acceptance Criteria
+
+1. When the user presses `Ctrl+/` (or `Meta+/` on macOS), the Hotkeys system shall open the shortcuts help modal.
+
+### Requirement 4: Preserve Multi-Key Sequence (Konami Code) Functionality
+
+**Objective:** As a user, I want multi-key sequences (Konami codes) to continue working after the migration, so that easter egg features remain accessible.
+
+#### Acceptance Criteria
+
+1. When the user enters the key sequence `↑↑↓↓←→←→BA`, the Hotkeys system shall show the staff credits modal.
+2. When the user enters the key sequence `XXBBAAYYA↓←`, the Hotkeys system shall apply the mirror mode CSS class to the document body.
+3. While a multi-key sequence is in progress, the Hotkeys system shall track partial matches and reset if an incorrect key is pressed.
+
+### Requirement 5: Input Element Focus Guard
+
+**Objective:** As a user, I want single-key shortcuts to not fire when I am typing in an input field, so that keyboard shortcuts do not interfere with text entry.
+
+#### Acceptance Criteria
+
+1. While an `<input>`, `<textarea>`, or `contenteditable` element is focused, the Hotkeys system shall suppress single-key shortcuts (e.g., `c`, `e`, `/`).
+2. While an `<input>`, `<textarea>`, or `contenteditable` element is focused, the Hotkeys system shall still allow modifier-key shortcuts (e.g., `Ctrl+/`).
+
+### Requirement 6: Lifecycle Management and Cleanup
+
+**Objective:** As a developer, I want hotkey bindings to be properly registered and cleaned up on component mount/unmount, so that there are no memory leaks or stale handlers.
+
+#### Acceptance Criteria
+
+1. When a layout component (BasicLayout or AdminLayout) mounts, the Hotkeys system shall register all hotkey bindings.
+2. When a layout component unmounts, the Hotkeys system shall unsubscribe all hotkey bindings.
+3. The Hotkeys system shall provide a cleanup mechanism compatible with React's `useEffect` return pattern.
+
+### Requirement 7: Maintain Subscriber Component Architecture
+
+**Objective:** As a developer, I want the subscriber-based architecture to be preserved or appropriately modernized, so that adding or modifying hotkeys remains straightforward.
+
+#### Acceptance Criteria
+
+1. The Hotkeys system shall support a pattern where each hotkey action is defined as an independent unit (component or handler) with its own key binding definition.
+2. When a new hotkey action is added, the developer shall be able to define it without modifying the core hotkey detection logic.
+3. The Hotkeys system shall support dynamic rendering of subscriber components when their associated hotkey fires.
+
+### Requirement 8: TypeScript Migration
+
+**Objective:** As a developer, I want the migrated hotkey system to use TypeScript, so that the code benefits from type safety and better IDE support.
+
+#### Acceptance Criteria
+
+1. The Hotkeys system shall be implemented in TypeScript (`.ts`/`.tsx` files) rather than JavaScript (`.js`/`.jsx`).
+2. The Hotkeys system shall export typed interfaces for hotkey definitions and handler signatures.

+ 23 - 0
.kiro/specs/hotkeys/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "hotkeys",
+  "created_at": "2026-02-20T00:00:00.000Z",
+  "updated_at": "2026-02-24T12:00:00.000Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true,
+  "cleanup_completed": true
+}

+ 29 - 0
.kiro/specs/hotkeys/tasks.md

@@ -0,0 +1,29 @@
+# Implementation Tasks
+
+## Summary
+
+All tasks completed. Migrated from `react-hotkeys` to `tinykeys` with subscriber-owned binding definitions and full TypeScript conversion.
+
+| Task | Description | Requirements |
+|------|-------------|--------------|
+| 1 | Write HotkeysManager tests (TDD) | 2, 3, 5 |
+| 2 | Rewrite HotkeysManager with tinykeys | 1, 2, 3, 4, 5, 6, 8 |
+| 3 | Remove legacy hotkey infrastructure | 1, 7 |
+| 4 | Verify quality and module reduction (-92 modules) | 1 |
+| 5 | Convert 4 JSX subscribers to TypeScript, fix bugs, unify patterns | 7, 8 |
+| 6.1 | Define shared types, add binding exports to all subscribers | 7, 8 |
+| 6.2 | Refactor HotkeysManager to build binding map from subscriber exports | 6, 7 |
+| 7 | Verify refactoring preserves all existing behavior | 1, 2, 3, 4, 5 |
+
+## Requirements Coverage
+
+| Requirement | Tasks |
+|-------------|-------|
+| 1. Replace react-hotkeys with tinykeys | 2, 3, 4, 7 |
+| 2. Preserve single-key shortcuts | 1, 2, 7 |
+| 3. Preserve modifier-key shortcuts | 1, 2, 7 |
+| 4. Preserve multi-key sequences | 2, 7 |
+| 5. Input element focus guard | 1, 2, 7 |
+| 6. Lifecycle management and cleanup | 2, 6.2 |
+| 7. Subscriber component architecture | 3, 5, 6.1, 6.2 |
+| 8. TypeScript migration | 2, 5, 6.1 |

+ 233 - 0
.kiro/specs/official-docker-image/design.md

@@ -0,0 +1,233 @@
+# Design Document: official-docker-image
+
+## Overview
+
+**Purpose**: Modernize the Dockerfile and entrypoint for the GROWI official Docker image based on 2025-2026 best practices, achieving enhanced security, optimized memory management, and improved build efficiency.
+
+**Users**: Infrastructure administrators (build/deploy), GROWI operators (memory tuning), and Docker image end users (usage via docker-compose).
+
+**Impact**: Redesign the existing 3-stage Dockerfile into a 5-stage configuration. Migrate the base image to Docker Hardened Images (DHI). Change the entrypoint from a shell script to TypeScript (using Node.js 24 native TypeScript execution), achieving a fully hardened configuration that requires no shell.
+
+### Goals
+
+- Up to 95% CVE reduction through DHI base image adoption
+- **Fully shell-free TypeScript entrypoint** — Node.js 24 native TypeScript execution (type stripping), maintaining the minimized attack surface of the DHI runtime as-is
+- Memory management via 3-tier fallback: `V8_MAX_HEAP_SIZE` / cgroup auto-calculation / V8 default
+- Environment variable names aligned with V8 option names (`V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`)
+- Improved build cache efficiency through the `turbo prune --docker` pattern
+- Privilege drop via gosu → `process.setuid/setgid` (Node.js native)
+
+### Non-Goals
+
+- Changes to Kubernetes manifests / Helm charts (GROWI.cloud `V8_MAX_HEAP_SIZE` configuration is out of scope)
+- Application code changes (adding gc(), migrating to .pipe(), etc. are separate specs)
+- Updating docker-compose.yml (documentation updates only)
+- Support for Node.js versions below 24
+- Adding HEALTHCHECK instructions (k8s uses its own probes, Docker Compose users can configure their own)
+
+## Architecture
+
+### Existing Architecture Analysis
+
+**Current Dockerfile 3-stage configuration:**
+
+| Stage | Base Image | Role |
+|-------|-----------|------|
+| `base` | `node:20-slim` | Install pnpm + turbo |
+| `builder` | `base` | `COPY . .` → install → build → artifacts |
+| release (unnamed) | `node:20-slim` | gosu install → artifact extraction → execution |
+
+**Main issues:**
+- `COPY . .` includes the entire monorepo in the build layer
+- pnpm version is hardcoded (`PNPM_VERSION="10.32.1"`)
+- Typo in `---frozen-lockfile`
+- Base image is node:20-slim (prone to CVE accumulation)
+- No memory management flags
+- No OCI labels
+- gosu installation requires apt-get (runtime dependency on apt)
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph BuildPhase
+        base[base stage<br>DHI dev + pnpm + turbo]
+        pruner[pruner stage<br>turbo prune --docker]
+        deps[deps stage<br>dependency install]
+        builder[builder stage<br>build + artifacts]
+    end
+
+    subgraph ReleasePhase
+        release[release stage<br>DHI runtime - no shell]
+    end
+
+    base --> pruner
+    pruner --> deps
+    deps --> builder
+    builder -->|artifacts| release
+
+    subgraph RuntimeFiles
+        entrypoint[docker-entrypoint.ts<br>TypeScript entrypoint]
+    end
+
+    entrypoint --> release
+```
+
+**Architecture Integration:**
+- Selected pattern: Multi-stage build with dependency caching separation
+- Domain boundaries: Build concerns (stages 1-4) vs Runtime concerns (stage 5 + entrypoint)
+- Existing patterns preserved: Production dependency extraction via pnpm deploy, tar.gz artifact transfer
+- New components: pruner stage (turbo prune), TypeScript entrypoint
+- **Key change**: gosu + shell script → TypeScript entrypoint (`process.setuid/setgid` + `fs` module + `child_process.execFileSync/spawn`). Eliminates the need for copying busybox/bash, maintaining the minimized attack surface of the DHI runtime as-is. Executes `.ts` directly via Node.js 24 type stripping
+- Steering compliance: Maintains Debian base (glibc performance), maintains monorepo build pattern
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Base Image (build) | `dhi.io/node:24-debian13-dev` | Base for build stages | apt/bash/git/util-linux available |
+| Base Image (runtime) | `dhi.io/node:24-debian13` | Base for release stage | Minimal configuration, 95% CVE reduction, **no shell** |
+| Entrypoint | Node.js (TypeScript) | Initialization, heap calculation, privilege drop, process startup | Node.js 24 native type stripping, no busybox/bash needed |
+| Privilege Drop | `process.setuid/setgid` (Node.js) | root → node user switch | No external binaries needed |
+| Build Tool | `turbo prune --docker` | Monorepo minimization | Official Turborepo recommendation |
+| Package Manager | pnpm (wget standalone) | Dependency management | corepack not adopted (scheduled for removal in Node.js 25+) |
+
+> For the rationale behind adopting the TypeScript entrypoint and comparison with busybox-static/setpriv, see `research.md`.
+
+## System Flows
+
+### Entrypoint Execution Flow
+
+```mermaid
+flowchart TD
+    Start[Container Start<br>as root via node entrypoint.ts] --> Setup[Directory Setup<br>fs.mkdirSync + symlinkSync + chownSync]
+    Setup --> HeapCalc{V8_MAX_HEAP_SIZE<br>is set?}
+    HeapCalc -->|Yes| UseEnv[Use V8_MAX_HEAP_SIZE]
+    HeapCalc -->|No| CgroupCheck{cgroup limit<br>detectable?}
+    CgroupCheck -->|Yes| AutoCalc[Auto-calculate<br>60% of cgroup limit]
+    CgroupCheck -->|No| NoFlag[No heap flag<br>V8 default]
+    UseEnv --> OptFlags[Check V8_OPTIMIZE_FOR_SIZE<br>and V8_LITE_MODE]
+    AutoCalc --> OptFlags
+    NoFlag --> OptFlags
+    OptFlags --> LogFlags[console.log applied flags]
+    LogFlags --> DropPriv[Drop privileges<br>process.setgid + setuid]
+    DropPriv --> Migration[Run migration<br>execFileSync node migrate-mongo]
+    Migration --> SpawnApp[Spawn app process<br>node --max-heap-size=X ... app.js]
+    SpawnApp --> SignalFwd[Forward SIGTERM/SIGINT<br>to child process]
+```
+
+**Key Decisions:**
+- Prioritize cgroup v2 (`/sys/fs/cgroup/memory.max`), fall back to v1
+- Treat cgroup v1 unlimited value (very large number) as no flag (threshold: 64GB)
+- `--max-heap-size` is passed to the spawned child process (the application itself), not the entrypoint process
+- Migration is invoked directly via `child_process.execFileSync` calling node (no `npm run`, no shell needed)
+- App startup uses `child_process.spawn` + signal forwarding to fulfill PID 1 responsibilities
+
+### Docker Build Flow
+
+```mermaid
+flowchart LR
+    subgraph Stage1[base]
+        S1[DHI dev image<br>+ pnpm + turbo]
+    end
+
+    subgraph Stage2[pruner]
+        S2A[COPY monorepo]
+        S2B[turbo prune --docker]
+    end
+
+    subgraph Stage3[deps]
+        S3A[COPY json + lockfile]
+        S3B[pnpm install --frozen-lockfile]
+    end
+
+    subgraph Stage4[builder]
+        S4A[COPY full source]
+        S4B[turbo run build]
+        S4C[pnpm deploy + tar.gz]
+    end
+
+    subgraph Stage5[release]
+        S5A[DHI runtime<br>no additional binaries]
+        S5B[Extract artifacts]
+        S5C[COPY entrypoint.js]
+    end
+
+    Stage1 --> Stage2 --> Stage3 --> Stage4
+    Stage4 -->|tar.gz| Stage5
+```
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Key Dependencies |
+|-----------|-------------|--------|-----------------|
+| Dockerfile | Infrastructure | 5-stage Docker image build definition | DHI images, turbo, pnpm |
+| docker-entrypoint.ts | Infrastructure | Container startup initialization (TypeScript) | Node.js fs/child_process, cgroup fs |
+| docker-entrypoint.spec.ts | Infrastructure | Unit tests for entrypoint | vitest |
+| Dockerfile.dockerignore | Infrastructure | Build context filter | — |
+| README.md | Documentation | Docker Hub image documentation | — |
+| buildspec.yml | CI/CD | CodeBuild build definition | AWS Secrets Manager, dhi.io |
+
+### Dockerfile
+
+**Responsibilities & Constraints**
+- 5-stage configuration: `base` → `pruner` → `deps` → `builder` → `release`
+- Use of DHI base images (`dhi.io/node:24-debian13-dev` / `dhi.io/node:24-debian13`)
+- **No shell or additional binary copying in runtime** (everything is handled by the Node.js entrypoint)
+
+**Stage Definitions:**
+- **base**: DHI dev image + pnpm (wget) + turbo + apt packages (`ca-certificates`, `wget`)
+- **pruner**: `COPY . .` + `turbo prune @growi/app --docker`
+- **deps**: COPY json/lockfile from pruner + `pnpm install --frozen-lockfile` + node-gyp
+- **builder**: COPY full source from pruner + `turbo run build` + `pnpm deploy` + artifact packaging
+- **release**: DHI runtime (no shell) + `COPY --from=builder` artifacts + entrypoint + OCI labels + EXPOSE/VOLUME
+
+### docker-entrypoint.ts
+
+**Responsibilities & Constraints**
+- Written in TypeScript, executed via Node.js 24 native type stripping (enums not allowed)
+- Directory setup as root (`/data/uploads` + symlink, `/tmp/page-bulk-export`)
+- Heap size determination via 3-tier fallback
+- Privilege drop via `process.setgid()` + `process.setuid()`
+- Migration execution via `child_process.execFileSync` (direct node invocation, no shell)
+- App process startup via `child_process.spawn` with signal forwarding (PID 1 responsibilities)
+- No external binary dependencies
+
+**Environment Variable Interface**
+
+| Variable | Type | Default | Description |
+|----------|------|---------|-------------|
+| `V8_MAX_HEAP_SIZE` | int (MB) | (unset) | Explicitly specify the --max-heap-size value for Node.js |
+| `V8_OPTIMIZE_FOR_SIZE` | `"true"` / (unset) | (unset) | Enable the --optimize-for-size flag |
+| `V8_LITE_MODE` | `"true"` / (unset) | (unset) | Enable the --lite-mode flag |
+
+> **Naming Convention**: Environment variable names are aligned with their corresponding V8 option names (`--max-heap-size`, `--optimize-for-size`, `--lite-mode`) prefixed with `V8_`. This improves discoverability and self-documentation compared to the previous `GROWI_`-prefixed names.
+
+**Batch Contract**
+- **Trigger**: On container startup (`ENTRYPOINT ["node", "/docker-entrypoint.ts"]`)
+- **Input validation**: V8_MAX_HEAP_SIZE (positive int, empty = unset), V8_OPTIMIZE_FOR_SIZE/V8_LITE_MODE (only `"true"` is valid), cgroup v2 (`memory.max`: numeric or `"max"`), cgroup v1 (`memory.limit_in_bytes`: numeric, large value = unlimited)
+- **Output**: Node flags passed directly as arguments to `child_process.spawn`
+- **Idempotency**: Executed on every restart, safe via `fs.mkdirSync({ recursive: true })`
+
+### README.md
+
+**Responsibilities & Constraints**
+- Docker Hub image documentation (published to hub.docker.com/r/growilabs/growi)
+- Document the V8 memory management environment variables under Configuration > Environment Variables section
+- Include variable name, type, default, and description for each: `V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`
+
+## Error Handling
+
+| Error | Category | Response |
+|-------|----------|----------|
+| cgroup file read failure | System | Warn and continue with no flag (V8 default) |
+| V8_MAX_HEAP_SIZE is invalid | User | Warn and continue with no flag (container still starts) |
+| Directory creation/permission failure | System | `process.exit(1)` — check volume mount configuration |
+| Migration failure | Business Logic | `execFileSync` throws → `process.exit(1)` — Docker/k8s restarts |
+| App process abnormal exit | System | Propagate child process exit code |
+
+## Performance & Scalability
+
+- **Build cache**: `turbo prune --docker` caches the dependency install layer. Skips dependency installation during rebuilds when only source code changes
+- **Image size**: No additional binaries in DHI runtime. Base layer is smaller compared to node:24-slim
+- **Memory efficiency**: Total heap control via `--max-heap-size` avoids the v24 trusted_space overhead issue. Prevents memory pressure in multi-tenant environments

+ 82 - 0
.kiro/specs/official-docker-image/requirements.md

@@ -0,0 +1,82 @@
+# Requirements Document
+
+## Introduction
+
+Modernize and optimize the GROWI official Docker image's Dockerfile (`apps/app/docker/Dockerfile`) and `docker-entrypoint.sh` based on 2025-2026 best practices. Target Node.js 24 and incorporate findings from the memory report (`apps/app/tmp/memory-results/REPORT.md`) to improve memory management.
+
+### Summary of Current State Analysis
+
+**Current Dockerfile structure:**
+- 3-stage structure: `base` → `builder` → `release` (based on node:20-slim)
+- Monorepo build with pnpm + turbo, production dependency extraction via `pnpm deploy`
+- Privilege drop from root to node user using gosu (after directory creation in entrypoint)
+- `COPY . .` copies the entire context into the builder
+- Application starts after running `npm run migrate` in CMD
+
+**GROWI-specific design intentions (items to maintain):**
+- Privilege drop pattern: The entrypoint must create and set permissions for `/data/uploads` and `/tmp/page-bulk-export` with root privileges, then drop to the node user for execution
+- `pnpm deploy --prod`: The official method for extracting only production dependencies from a pnpm monorepo
+- Inter-stage artifact transfer via tar.gz: Cleanly transfers build artifacts to the release stage
+- `apps/app/tmp` directory: Required in the production image as files are placed there during operation
+- `--expose_gc` flag: Required for explicitly calling `gc()` in batch processing (ES rebuild, import, etc.)
+- `npm run migrate` in CMD: Automatically runs migrations at startup for the convenience of Docker image users
+
+**References:**
+- [Future Architect: 2024 Dockerfile Best Practices](https://future-architect.github.io/articles/20240726a/)
+- [Snyk: 10 best practices to containerize Node.js](https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/)
+- [ByteScrum: Dockerfile Best Practices 2025](https://blog.bytescrum.com/dockerfile-best-practices-2025-secure-fast-and-modern)
+- [OneUptime: Docker Health Check Best Practices 2026](https://oneuptime.com/blog/post/2026-01-30-docker-health-check-best-practices/view)
+- [Docker: Introduction to heredocs in Dockerfiles](https://www.docker.com/blog/introduction-to-heredocs-in-dockerfiles/)
+- [Docker Hardened Images: Node.js Migration Guide](https://docs.docker.com/dhi/migration/examples/node/)
+- [Docker Hardened Images Catalog: Node.js](https://hub.docker.com/hardened-images/catalog/dhi/node)
+- GROWI Memory Usage Investigation Report (`apps/app/tmp/memory-results/REPORT.md`)
+
+## Requirements
+
+### Requirement 1: Modernize Base Image and Build Environment
+
+**Objective:** As an infrastructure administrator, I want the Dockerfile's base image and syntax to comply with the latest best practices, so that security patch application, performance improvements, and maintainability enhancements are achieved
+
+**Summary**: DHI base images adopted (`dhi.io/node:24-debian13-dev` for build, `dhi.io/node:24-debian13` for release) with up to 95% CVE reduction. Syntax directive updated to auto-follow latest stable. pnpm installed via wget standalone script (corepack not adopted due to planned removal in Node.js 25+). Fixed `---frozen-lockfile` typo and eliminated hardcoded pnpm version.
+
+### Requirement 2: Memory Management Optimization
+
+**Objective:** As a GROWI operator, I want the Node.js heap size to be appropriately controlled according to container memory constraints, so that the risk of OOMKilled is reduced and memory efficiency in multi-tenant environments is improved
+
+**Summary**: 3-tier heap size fallback implemented in docker-entrypoint.ts: (1) `GROWI_HEAP_SIZE` env var, (2) cgroup v2/v1 auto-calculation at 60%, (3) V8 default. Uses `--max-heap-size` (not `--max_old_space_size`) passed as direct spawn arguments (not `NODE_OPTIONS`). Additional flags: `--optimize-for-size` via `GROWI_OPTIMIZE_MEMORY=true`, `--lite-mode` via `GROWI_LITE_MODE=true`.
+
+### Requirement 3: Build Efficiency and Cache Optimization
+
+**Objective:** As a developer, I want Docker builds to be fast and efficient, so that CI/CD pipeline build times are reduced and image size is minimized
+
+**Summary**: `turbo prune --docker` pattern adopted to eliminate `COPY . .` and maximize layer cache (dependency install cached separately from source changes). pnpm store and apt-get cache mounts maintained. `.next/cache` excluded from release stage. Artifact transfer uses `COPY --from=builder` (adapted from design's `--mount=type=bind,from=builder` due to shell-less DHI runtime).
+
+### Requirement 4: Security Hardening
+
+**Objective:** As a security officer, I want the Docker image to comply with security best practices, so that the attack surface is minimized and the safety of the production environment is improved
+
+**Summary**: Non-root execution via Node.js native `process.setuid/setgid` (no gosu/setpriv). Release stage contains no unnecessary packages — no shell, no apt, no build tools. Enhanced `.dockerignore` excludes `.git`, secrets, test files, IDE configs. `--no-install-recommends` used for apt-get in build stage.
+
+### Requirement 5: Operability and Observability Improvement
+
+**Objective:** As an operations engineer, I want the Docker image to have appropriate metadata configured, so that management by container orchestrators is facilitated
+
+**Summary**: OCI standard LABEL annotations added (`org.opencontainers.image.source`, `.title`, `.description`, `.vendor`). `EXPOSE 3000` and `VOLUME /data` maintained.
+
+### Requirement 6: Entrypoint and CMD Refactoring
+
+**Objective:** As a developer, I want the entrypoint script and CMD to have a clear and maintainable structure, so that dynamic assembly of memory flags and future extensions are facilitated
+
+**Summary**: Entrypoint rewritten in TypeScript (`docker-entrypoint.ts`) executed via Node.js 24 native type stripping. Handles: directory setup (`/data/uploads`, `/tmp/page-bulk-export`), heap size calculation (3-tier fallback), privilege drop (`process.setgid` + `process.setuid`), migration execution (`execFileSync`), app process spawn with signal forwarding. Always includes `--expose_gc`. Logs applied flags to stdout.
+
+### Requirement 7: Backward Compatibility
+
+**Objective:** As an existing Docker image user, I want existing operations to not break when migrating to the new Dockerfile, so that the risk during upgrades is minimized
+
+**Summary**: Full backward compatibility maintained. Environment variables (`MONGO_URI`, `FILE_UPLOAD`, etc.), `VOLUME /data`, port 3000, and docker-compose usage patterns all work as before. Without memory management env vars, behavior is equivalent to V8 defaults.
+
+### Requirement 8: Production Replacement and CI/CD Support
+
+**Objective:** As an infrastructure administrator, I want the artifacts in the docker-new directory to officially replace the existing docker directory and the CI/CD pipeline to operate with the new Dockerfile, so that DHI-based images are used in production builds
+
+**Summary**: All files moved from `apps/app/docker-new/` to `apps/app/docker/`, old files deleted. Dockerfile self-referencing path updated. `docker login dhi.io` added to buildspec.yml pre_build phase, reusing existing `DOCKER_REGISTRY_PASSWORD` secret. `codebuild/` directory and `README.md` maintained.

+ 288 - 0
.kiro/specs/official-docker-image/research.md

@@ -0,0 +1,288 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Discovery findings and design decision rationale for the official Docker image modernization.
+---
+
+## Summary
+- **Feature**: `official-docker-image`
+- **Discovery Scope**: Extension (major improvement of existing Dockerfile)
+- **Key Findings**:
+  - The DHI runtime image (`dhi.io/node:24-debian13`) is a minimal configuration that does not include a shell, package manager, or coreutils. By adopting a Node.js entrypoint (TypeScript), a configuration requiring no shell or additional binaries is achieved
+  - `--mount=type=bind` is impractical for monorepo multi-step builds. `turbo prune --docker` is the officially recommended Docker optimization approach by Turborepo
+  - gosu is replaced by Node.js native `process.setuid/setgid`. External binaries (gosu/setpriv/busybox) are completely unnecessary
+  - HEALTHCHECK is not adopted (k8s uses its own probes. Docker Compose users can configure it themselves)
+  - Node.js 24 supports native TypeScript execution (type stripping). The entrypoint can be written in TypeScript
+
+## Research Log
+
+### DHI Runtime Image Configuration
+
+- **Context**: Investigation of constraints when adopting `dhi.io/node:24-debian13` as the base image for the release stage
+- **Sources Consulted**:
+  - [DHI Catalog GitHub](https://github.com/docker-hardened-images/catalog) — `image/node/debian-13/` directory
+  - [DHI Documentation](https://docs.docker.com/dhi/)
+  - [DHI Use an Image](https://docs.docker.com/dhi/how-to/use/)
+- **Findings**:
+  - Pre-installed packages in the runtime image: only `base-files`, `ca-certificates`, `libc6`, `libgomp1`, `libstdc++6`, `netbase`, `tzdata`
+  - **No shell**, **no apt**, **no coreutils**, **no curl/wget**
+  - Default user: `node` (UID 1000, GID 1000)
+  - Dev image (`-dev`): `apt`, `bash`, `git`, `util-linux`, `coreutils`, etc. are pre-installed
+  - Available tags: `dhi.io/node:24-debian13`, `dhi.io/node:24-debian13-dev`
+  - Platforms: `linux/amd64`, `linux/arm64`
+- **Implications**:
+  - By writing the entrypoint in Node.js (TypeScript), neither a shell nor additional binaries are needed at all
+  - gosu/setpriv are replaced by Node.js native `process.setuid/setgid`. No need to copy external binaries
+  - HEALTHCHECK is not adopted (k8s uses its own probes). Health checks via curl/Node.js http module are unnecessary
+
+### Applicability of `--mount=type=bind` in Monorepo Builds
+
+- **Context**: Investigation of the feasibility of Requirement 3.1 "Use `--mount=type=bind` instead of `COPY . .` in the builder stage"
+- **Sources Consulted**:
+  - [Docker Build Cache Optimization](https://docs.docker.com/build/cache/optimize/)
+  - [Dockerfile Reference - RUN --mount](https://docs.docker.com/reference/dockerfile/)
+  - [pnpm Docker Documentation](https://pnpm.io/docker)
+  - [Turborepo Docker Guide](https://turbo.build/repo/docs/handbook/deploying-with-docker)
+- **Findings**:
+  - `--mount=type=bind` is **only valid during the execution of a RUN instruction** and is not carried over to the next RUN instruction
+  - In the multi-step process of monorepo builds (install -> build -> deploy), each step depends on artifacts from the previous step, making it difficult to achieve with bind mounts alone
+  - It is possible to combine all steps into a single RUN, but this loses the benefits of layer caching
+  - **Turborepo official recommendation**: Use `turbo prune --docker` to minimize the monorepo for Docker
+    - `out/json/` — only package.json files needed for dependency install
+    - `out/pnpm-lock.yaml` — lockfile
+    - `out/full/` — source code needed for the build
+  - This approach avoids `COPY . .` while leveraging layer caching
+- **Implications**:
+  - Requirement 3.1 should be achieved using the `turbo prune --docker` pattern instead of `--mount=type=bind`
+  - The goal (minimizing source code layers / improving cache efficiency) can be equally achieved
+  - **However**, compatibility of `turbo prune --docker` with pnpm workspaces needs to be verified during implementation
+
+### Alternatives to gosu
+
+- **Context**: Investigation of alternatives since gosu is not available in the DHI runtime image
+- **Sources Consulted**:
+  - [gosu GitHub](https://github.com/tianon/gosu) — list of alternative tools
+  - [Debian Packages - gosu in trixie](https://packages.debian.org/trixie/admin/gosu)
+  - [PhotoPrism: Switch from gosu to setpriv](https://github.com/photoprism/photoprism/pull/2730)
+  - [MongoDB Docker: Replace gosu by setpriv](https://github.com/docker-library/mongo/pull/714)
+  - Node.js `process.setuid/setgid` documentation
+- **Findings**:
+  - `setpriv` is part of `util-linux` and is pre-installed in the DHI dev image
+  - `gosu node command` can be replaced with `setpriv --reuid=node --regid=node --init-groups -- command`
+  - PhotoPrism and the official MongoDB Docker image have already migrated from gosu to setpriv
+  - **Node.js native**: Can be fully replaced with `process.setgid(1000)` + `process.setuid(1000)` + `process.initgroups('node', 1000)`
+  - When adopting a Node.js entrypoint, no external binaries (gosu/setpriv/busybox) are needed at all
+- **Implications**:
+  - **Final decision**: Adopt Node.js native `process.setuid/setgid` (setpriv is also unnecessary)
+  - No need to copy gosu/setpriv binaries, resulting in no additional binaries in the release stage
+  - Maintains the minimized attack surface of the DHI runtime as-is
+
+### HEALTHCHECK Implementation Approach (Not Adopted)
+
+- **Context**: Investigation of HEALTHCHECK implementation approaches since curl is not available in the DHI runtime image
+- **Sources Consulted**:
+  - [Docker Healthchecks in Distroless Node.js](https://www.mattknight.io/blog/docker-healthchecks-in-distroless-node-js)
+  - [Docker Healthchecks: Why Not to Use curl](https://blog.sixeyed.com/docker-healthchecks-why-not-to-use-curl-or-iwr/)
+  - GROWI healthcheck endpoint: `apps/app/src/server/routes/apiv3/healthcheck.ts`
+- **Findings**:
+  - Node.js `http` module is sufficient (curl is unnecessary)
+  - GROWI's `/_api/v3/healthcheck` endpoint returns `{ status: 'OK' }` without any parameters
+  - Docker HEALTHCHECK is useful for Docker Compose's `depends_on: service_healthy` dependency order control
+  - In k8s environments, custom probes (liveness/readiness) are used, so the Dockerfile's HEALTHCHECK is unnecessary
+- **Implications**:
+  - **Final decision: Not adopted**. k8s uses its own probes, and Docker Compose users can configure it themselves in compose.yaml
+  - By not including HEALTHCHECK in the Dockerfile, simplicity is maintained
+
+### Shell Dependency of npm run migrate
+
+- **Context**: Investigation of whether `npm run migrate` within CMD requires a shell
+- **Sources Consulted**:
+  - GROWI `apps/app/package.json`'s `migrate` script
+- **Findings**:
+  - The actual `migrate` script content: `node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js`
+  - `npm run` internally uses `sh -c`, so a shell is required
+  - Alternative: Running the script contents directly with node eliminates the need for npm/sh
+  - However, using npm run is more maintainable (can track changes in package.json)
+- **Implications**:
+  - **Final decision**: Use `child_process.execFileSync` in the Node.js entrypoint to directly execute the migration command (not using npm run, no shell needed)
+  - Adopt the approach of directly writing the `migrate` script contents within the entrypoint
+  - When package.json changes, the entrypoint also needs to be updated, but priority is given to fully shell-less DHI runtime
+
+### Node.js 24 Native TypeScript Execution
+
+- **Context**: Investigation of whether Node.js 24's native TypeScript execution feature can be used when writing the entrypoint in TypeScript
+- **Sources Consulted**:
+  - [Node.js 23 Release Notes](https://nodejs.org/en/blog/release/v23.0.0) — `--experimental-strip-types` unflagged
+  - [Node.js Type Stripping Documentation](https://nodejs.org/docs/latest/api/typescript.html)
+- **Findings**:
+  - From Node.js 23, type stripping is enabled by default (no `--experimental-strip-types` flag needed)
+  - Available as a stable feature in Node.js 24
+  - **Constraint**: "Non-erasable syntax" such as enum and namespace cannot be used. `--experimental-transform-types` is required for those
+  - interface, type alias, and type annotations (`: string`, `: number`, etc.) can be used without issues
+  - Can be executed directly with `ENTRYPOINT ["node", "docker-entrypoint.ts"]`
+- **Implications**:
+  - The entrypoint can be written in TypeScript, enabling type-safe implementation
+  - Do not use enum; use union types (`type Foo = 'a' | 'b'`) as alternatives
+  - tsconfig.json is not required (type stripping operates independently)
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| DHI runtime + busybox-static | Copy busybox-static to provide sh/coreutils | Minimal addition (~1MB) enables full functionality | Contradicts the original intent of DHI adoption (minimizing attack surface). Additional binaries are attack vectors | Rejected |
+| DHI runtime + bash/coreutils copy | Copy bash and various binaries individually from the dev stage | Full bash functionality available | Shared library dependencies are complex, many files need to be copied | Rejected |
+| DHI dev image as runtime | Use the dev image as-is for production | Minimal configuration changes | Increased attack surface due to apt/git etc., diminishes the meaning of DHI | Rejected |
+| Node.js entrypoint (TypeScript, shell-less) | Write the entrypoint in TypeScript. Runs with Node.js 24's native TypeScript execution | Completely shell-free, maintains DHI runtime's attack surface as-is, type-safe | Migration command written directly (not using npm run), updates needed when package.json changes | **Adopted** |
+
+## Design Decisions
+
+### Decision: Node.js TypeScript Entrypoint (Completely Shell-Free)
+
+- **Context**: The DHI runtime image contains neither a shell nor coreutils. Copying busybox-static contradicts the intent of DHI adoption (minimizing attack surface)
+- **Alternatives Considered**:
+  1. Copy busybox-static to provide shell + coreutils — Contradicts DHI's attack surface minimization
+  2. Copy bash + coreutils individually — Complex dependencies
+  3. Node.js TypeScript entrypoint — Everything can be accomplished with `fs`, `child_process`, and `process.setuid/setgid`
+- **Selected Approach**: Write the entrypoint in TypeScript (`docker-entrypoint.ts`). Execute directly using Node.js 24's native TypeScript execution (type stripping)
+- **Rationale**: No additional binaries needed in the DHI runtime whatsoever. Directory operations via fs module, privilege dropping via process.setuid/setgid, migration via execFileSync, and app startup via spawn. Improved maintainability through type safety
+- **Trade-offs**: Migration command is written directly (not using npm run). When the migrate script in package.json changes, the entrypoint also needs to be updated
+- **Follow-up**: Verify that Node.js 24's type stripping works correctly with a single-file entrypoint without import statements
+
+### Decision: Privilege Dropping via Node.js Native process.setuid/setgid
+
+- **Context**: gosu cannot be installed in the DHI runtime. busybox-static/setpriv are also not adopted (policy of eliminating additional binaries)
+- **Alternatives Considered**:
+  1. Copy gosu binary — Works but goes against industry trends
+  2. Copy setpriv binary — Works but goes against the policy of eliminating additional binaries
+  3. Node.js `process.setuid/setgid` — Standard Node.js API
+  4. Docker `--user` flag — Cannot handle dynamic processing in the entrypoint
+- **Selected Approach**: Drop privileges with `process.initgroups('node', 1000)` + `process.setgid(1000)` + `process.setuid(1000)`
+- **Rationale**: No external binaries needed at all. Can be called directly within the Node.js entrypoint. Safe privilege dropping in the order setgid -> setuid
+- **Trade-offs**: The entrypoint starts as a Node.js process running as root, and the app becomes its child process (not an exec like gosu). However, the app process is separated via spawn, and signal forwarding fulfills PID 1 responsibilities
+- **Follow-up**: None
+
+### Decision: turbo prune --docker Pattern
+
+- **Context**: Requirement 3.1 requires eliminating `COPY . .`, but `--mount=type=bind` is impractical for monorepo builds
+- **Alternatives Considered**:
+  1. `--mount=type=bind` — Does not persist across RUN instructions, unsuitable for multi-step builds
+  2. Combine all steps into a single RUN — Poor cache efficiency
+  3. `turbo prune --docker` — Officially recommended by Turborepo
+- **Selected Approach**: Use `turbo prune --docker` to minimize the monorepo for Docker, using optimized COPY patterns
+- **Rationale**: Officially recommended by Turborepo. Separates dependency install and source copy to maximize layer cache utilization. Eliminates `COPY . .` while remaining practical
+- **Trade-offs**: One additional build stage (pruner stage), but offset by improved cache efficiency
+- **Follow-up**: Verify `turbo prune --docker` compatibility with pnpm workspaces during implementation
+
+### Decision: Flag Injection via spawn Arguments
+
+- **Context**: `--max-heap-size` cannot be used in `NODE_OPTIONS`. It needs to be passed as a direct argument to the node command
+- **Alternatives Considered**:
+  1. Export environment variable `GROWI_NODE_FLAGS` and inject via shell variable expansion in CMD — Requires a shell
+  2. Rewrite CMD string with sed in the entrypoint — Fragile
+  3. Pass directly as arguments to `child_process.spawn` in the Node.js entrypoint — No shell needed
+- **Selected Approach**: Build a flag array within the entrypoint and pass it directly with `spawn(process.execPath, [...nodeFlags, ...appArgs])`
+- **Rationale**: No shell variable expansion needed. Passed directly as an array, resulting in zero risk of shell injection. Natural integration with the Node.js entrypoint
+- **Trade-offs**: CMD becomes unnecessary (the entrypoint handles all startup processing). Overriding the command with docker run does not affect the logic within the entrypoint
+- **Follow-up**: None
+
+### DHI Registry Authentication and CI/CD Integration
+
+- **Context**: Investigation of the authentication method required for pulling DHI base images and how to integrate with the existing CodeBuild pipeline
+- **Sources Consulted**:
+  - [DHI How to Use an Image](https://docs.docker.com/dhi/how-to/use/) — DHI usage instructions
+  - Existing `apps/app/docker/codebuild/buildspec.yml` — Current CodeBuild build definition
+  - Existing `apps/app/docker/codebuild/secretsmanager.tf` — AWS Secrets Manager configuration
+- **Findings**:
+  - DHI uses Docker Hub credentials (DHI is a feature of Docker Business/Team subscriptions)
+  - Authentication is possible with `docker login dhi.io --username <dockerhub-user> --password-stdin`
+  - The existing buildspec.yml is already logged into docker.io with the `DOCKER_REGISTRY_PASSWORD` secret
+  - The same credentials can be used to log into `dhi.io` as well (no additional secrets required)
+  - The flow of CodeBuild's `reusable-app-build-image.yml` -> CodeBuild Project -> buildspec.yml does not need to change
+- **Implications**:
+  - Can be addressed by simply adding one line of `docker login dhi.io` to the pre_build in buildspec.yml
+  - No changes to `secretsmanager.tf` are needed
+  - Login to both Docker Hub and DHI is required (docker.io for push, dhi.io for pull)
+
+### Impact Scope of Directory Replacement (Codebase Investigation)
+
+- **Context**: Confirming that existing references will not break when replacing `apps/app/docker-new/` with `apps/app/docker/`
+- **Sources Consulted**: Grep investigation of the entire codebase with the `apps/app/docker` keyword
+- **Findings**:
+  - `buildspec.yml`: `-f ./apps/app/docker/Dockerfile` — Same path after replacement (no change needed)
+  - `codebuild.tf`: `buildspec = "apps/app/docker/codebuild/buildspec.yml"` — Same (no change needed)
+  - `.github/workflows/release.yml`: `readme-filepath: ./apps/app/docker/README.md` — Same (no change needed)
+  - `.github/workflows/ci-app.yml` / `ci-app-prod.yml`: `!apps/app/docker/**` exclusion pattern — Same (no change needed)
+  - `apps/app/bin/github-actions/update-readme.sh`: `cd docker` + sed — Same (no change needed)
+  - Within Dockerfile: line 122 `apps/app/docker-new/docker-entrypoint.ts` — **Needs updating** (self-referencing path)
+  - `package.json` and `vitest.config` for docker-related references — None
+  - `lefthook.yml` for docker-related hooks — None
+- **Implications**:
+  - Only one location within the Dockerfile (self-referencing path) needs to be updated during replacement
+  - All external references (CI/CD, GitHub Actions) already use the `apps/app/docker/` path and require no changes
+  - The `codebuild/` directory and `README.md` are maintained as-is within `docker/`
+
+### Environment Variable Renaming: GROWI_ prefix → V8_ prefix
+
+- **Context**: The initial implementation used `GROWI_HEAP_SIZE`, `GROWI_OPTIMIZE_MEMORY`, and `GROWI_LITE_MODE` as environment variable names. These names obscure the relationship between the env var and the underlying V8 flag it controls
+- **Motivation**: Align environment variable names with the actual V8 option names they map to, improving discoverability and self-documentation
+- **Mapping**:
+  | Old Name | New Name | V8 Flag |
+  |----------|----------|---------|
+  | `GROWI_HEAP_SIZE` | `V8_MAX_HEAP_SIZE` | `--max-heap-size` |
+  | `GROWI_OPTIMIZE_MEMORY` | `V8_OPTIMIZE_FOR_SIZE` | `--optimize-for-size` |
+  | `GROWI_LITE_MODE` | `V8_LITE_MODE` | `--lite-mode` |
+- **Benefits**:
+  - Users can immediately understand which V8 flag each variable controls
+  - Naming convention is consistent: `V8_` prefix + option name in UPPER_SNAKE_CASE
+  - No need to consult documentation to understand the mapping
+- **Impact scope**:
+  - `docker-entrypoint.ts`: Code changes (env var reads, comments, log messages)
+  - `docker-entrypoint.spec.ts`: Test updates (env var references in test cases)
+  - `README.md`: Add documentation for the new environment variables
+  - `design.md`, `requirements.md`, `tasks.md`: Spec document updates
+- **Breaking change**: Yes — users who have configured `GROWI_HEAP_SIZE`, `GROWI_OPTIMIZE_MEMORY`, or `GROWI_LITE_MODE` in their docker-compose.yml or deployment configs will need to update to the new names. This is acceptable as these variables were introduced in the same release (v7.5.x) and have not been published yet
+- **Implications**: No backward compatibility shim needed since the variables are new in this version
+
+## Risks & Mitigations
+
+- **Stability of Node.js 24 native TypeScript execution**: Type stripping was unflagged in Node.js 23. It is a stable feature in Node.js 24. However, non-erasable syntax such as enum cannot be used -> Use only interface/type
+- **Direct description of migration command**: The `migrate` script from package.json is written directly in the entrypoint, so synchronization is needed when changes occur -> Clearly noted in comments during implementation
+- **turbo prune compatibility with pnpm workspaces**: Verify during implementation. If incompatible, fall back to an optimized COPY pattern
+- **Limitations of process.setuid/setgid**: `process.initgroups` is required for supplementary group initialization. The order setgid -> setuid must be strictly followed
+- **docker login requirement for DHI images**: `docker login dhi.io` is required in CI/CD. Security considerations for credential management are needed
+
+## Production Implementation Discoveries
+
+### DHI Dev Image Minimal Configuration (Phase 1 E2E)
+
+- **Issue**: The DHI dev image (`dhi.io/node:24-debian13-dev`) did not include the `which` command
+- **Resolution**: Changed pnpm installation from `SHELL="$(which sh)"` to `SHELL=/bin/sh`
+- **Impact**: Minor — only affects the pnpm install script invocation
+
+### Complete Absence of Shell in DHI Runtime Image (Phase 1 E2E)
+
+- **Issue**: The DHI runtime image (`dhi.io/node:24-debian13`) did not have `/bin/sh`. The design planned `--mount=type=bind,from=builder` + `RUN tar -zxf`, but `RUN` instructions require `/bin/sh`
+- **Resolution**:
+  - **builder stage**: Changed from `tar -zcf` to `cp -a` into a staging directory `/tmp/release/`
+  - **release stage**: Changed from `RUN --mount=type=bind... tar -zxf` to `COPY --from=builder --chown=node:node`
+- **Impact**: Design Req 3.5 (`--mount=type=bind,from=builder` pattern) was replaced with `COPY --from=builder`. The security goal of not requiring a shell at runtime was achieved even more robustly
+- **Lesson**: DHI runtime images are truly minimal — `COPY`, `WORKDIR`, `ENV`, `LABEL`, `ENTRYPOINT` are processed by the Docker daemon and do not require a shell
+
+### process.initgroups() Type Definition Gap
+
+- **Issue**: `process.initgroups('node', 1000)` was called for in the design, but implementation was deferred because the type definition does not exist in `@types/node`
+- **Status**: Deferred (Known Issue)
+- **Runtime**: `process.initgroups` does exist at runtime in Node.js 24
+- **Workaround options**: Wait for `@types/node` fix, or use `(process as any).initgroups('node', 1000)`
+- **Practical impact**: Low — the node user in a Docker container typically has no supplementary groups
+
+## References
+
+- [Docker Hardened Images Documentation](https://docs.docker.com/dhi/) — Overview and usage of DHI
+- [DHI Catalog GitHub](https://github.com/docker-hardened-images/catalog) — Image definitions and tag list
+- [Turborepo Docker Guide](https://turbo.build/repo/docs/handbook/deploying-with-docker) — turbo prune --docker pattern
+- [pnpm Docker Documentation](https://pnpm.io/docker) — pnpm Docker build recommendations
+- [Future Architect: 2024 Edition Dockerfile Best Practices](https://future-architect.github.io/articles/20240726a/) — Modern Dockerfile syntax
+- [MongoDB Docker: gosu -> setpriv](https://github.com/docker-library/mongo/pull/714) — Precedent for setpriv migration
+- [Docker Healthchecks in Distroless](https://www.mattknight.io/blog/docker-healthchecks-in-distroless-node-js) — Health checks without curl
+- GROWI memory usage investigation report (`apps/app/tmp/memory-results/REPORT.md`) — Basis for heap size control

+ 22 - 0
.kiro/specs/official-docker-image/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "official-docker-image",
+  "created_at": "2026-02-20T00:00:00.000Z",
+  "updated_at": "2026-02-24T15:30:00.000Z",
+  "language": "en",
+  "phase": "tasks-generated",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": false
+    }
+  },
+  "ready_for_implementation": true
+}

+ 193 - 0
.kiro/specs/official-docker-image/tasks.md

@@ -0,0 +1,193 @@
+# Implementation Plan
+
+> **Task ordering design policy**:
+> - **Phase 1 (this phase)**: Reproduce an image with the same specifications as the current one using a DHI base image + TypeScript entrypoint. The build pipeline (3-stage structure using `COPY . .`) is kept as-is, **prioritizing a safe runtime migration**.
+> - **Phase 2 (next phase)**: Introduction of build optimization via the `turbo prune --docker` pattern. This will be done after runtime is stable in Phase 1. Adding pruner/deps stages to create a 5-stage structure.
+>
+> **Implementation directory**: Create new files in `apps/app/docker-new/`. The existing `apps/app/docker/` will not be modified at all. Maintain a state where parallel comparison and verification is possible.
+>
+> Directory permission handling is implemented and tested as the highest priority to detect regressions early. Since the entrypoint (TypeScript) and Dockerfile are independent files, some tasks can be executed in parallel.
+
+## Phase 1: DHI + TypeScript entrypoint (maintaining current build pattern)
+
+- [x] 1. (P) Strengthen build context filter
+  - Add `.git`, `.env*` (except production), test files, IDE configuration files, etc. to the current exclusion rules
+  - Verify that security-sensitive files (secrets, credentials) are not included in the context
+  - Maintain the current exclusion rules (`node_modules`, `.next`, `.turbo`, `apps/slackbot-proxy`, etc.)
+  - _Requirements: 4.3_
+
+- [x] 2. TypeScript entrypoint directory initialization and permission management
+- [x] 2.1 (P) Create entrypoint skeleton and recursive chown helper
+  - Create a new TypeScript file that can be directly executed with Node.js 24 type stripping (no enums, erasable syntax only)
+  - Structure the main execution flow as a `main()` function with top-level try-catch for error handling
+  - Implement a helper function that recursively changes ownership of files and subdirectories within a directory
+  - Create unit tests for the helper function (verify recursive behavior with nested directory structures)
+  - _Requirements: 6.8_
+
+- [x] 2.2 Implement directory initialization processing
+  - Implement creation of `/data/uploads`, symlink creation to `./public/uploads`, and recursive ownership change
+  - Implement creation of `/tmp/page-bulk-export`, recursive ownership change, and permission 700 setting
+  - Ensure idempotency (`recursive: true` for mkdir, prevent duplicate symlink creation)
+  - Create unit tests that **guarantee the same behavior as the current `docker-entrypoint.sh`** (using fs mocks, verifying each state of directories, symlinks, ownership, and permissions)
+  - Verify that the process exits (exit code 1) on failure (e.g., volume mount not configured)
+  - _Requirements: 6.3, 6.4_
+
+- [x] 2.3 Implement privilege dropping
+  - Implement demotion from root to node user (UID 1000, GID 1000)
+  - Initialize supplementary groups, strictly following the order of setgid then setuid (reverse order causes setgid to fail)
+  - Output an error message and exit the process on privilege drop failure
+  - _Requirements: 4.1, 6.2_
+
+- [x] 3. Heap size calculation and node flag assembly
+- [x] 3.1 (P) Implement cgroup memory limit detection
+  - Implement reading and numeric parsing of cgroup v2 files (treat the `"max"` string as unlimited)
+  - Implement fallback to cgroup v1 files (treat values exceeding 64GB as unlimited)
+  - Calculate 60% of the memory limit as the heap size (in MB)
+  - On file read failure, output a warning log and continue without flags (V8 default)
+  - Create unit tests for each pattern (v2 normal detection, v2 unlimited, v1 fallback, v1 unlimited, detection unavailable)
+  - _Requirements: 2.2, 2.3_
+
+- [x] 3.2 (P) Implement heap size specification via environment variable
+  - Implement parsing and validation of the `GROWI_HEAP_SIZE` environment variable (positive integer, in MB)
+  - On invalid values (NaN, negative numbers, empty string), output a warning log and fall back to no flags
+  - Confirm via tests that the environment variable takes priority over cgroup auto-calculation
+  - _Requirements: 2.1_
+
+- [x] 3.3 Implement node flag assembly and log output
+  - Implement the 3-tier fallback integration logic (environment variable -> cgroup calculation -> V8 default)
+  - Always include the `--expose_gc` flag
+  - Add `--optimize-for-size` when `GROWI_OPTIMIZE_MEMORY=true`, and `--lite-mode` when `GROWI_LITE_MODE=true`
+  - Pass `--max-heap-size` directly as a spawn argument (do not use `--max_old_space_size`, do not include in `NODE_OPTIONS`)
+  - Log the applied flags to stdout (including which tier determined the value)
+  - Create unit tests for each combination of environment variables (all unset, HEAP_SIZE only, all enabled, etc.)
+  - _Requirements: 2.4, 2.5, 2.6, 2.7, 6.1, 6.6, 6.7_
+
+- [x] 4. Migration execution and app process management
+- [x] 4.1 Direct migration execution
+  - Execute migrate-mongo by directly calling the node binary (do not use npm run, do not go through a shell)
+  - Inherit stdio to display migration logs
+  - On migration failure, catch the exception and exit the process, prompting restart by the container orchestrator
+  - _Requirements: 6.5_
+
+- [x] 4.2 App process startup and signal management
+  - Start the application as a child process with the calculated node flags included in the arguments
+  - Forward SIGTERM, SIGINT, and SIGHUP to the child process
+  - Propagate the child process exit code (or signal) as the entrypoint exit code
+  - Create tests to verify PID 1 responsibilities (signal forwarding, child process reaping, graceful shutdown)
+  - _Requirements: 6.2, 6.5_
+
+- [x] 5. Dockerfile reconstruction (current 3-stage pattern + DHI)
+- [x] 5.1 (P) Build the base stage
+  - Set the DHI dev image as the base and update the syntax directive to auto-follow the latest stable version
+  - Install pnpm via wget standalone script (eliminate hardcoded versions)
+  - Install turbo globally
+  - Install packages required for building with `--no-install-recommends` and apply apt cache mounts
+  - _Requirements: 1.1, 1.2, 1.3, 1.5, 3.3, 4.4_
+
+- [x] 5.2 Build the builder stage
+  - Maintain the current `COPY . .` pattern to copy the entire monorepo, then install dependencies, build, and extract production dependencies
+  - Fix the `--frozen-lockfile` typo (3 dashes -> 2 dashes)
+  - Configure pnpm store cache mounts to reduce rebuild time
+  - Extract only production dependencies and package them into tar.gz (including the `apps/app/tmp` directory)
+  - Guarantee that `.next/cache` is not included in the artifact
+  - _Requirements: 1.4, 3.2, 3.4_
+
+- [x] 5.3 Build the release stage
+  - Set the DHI runtime image as the base with no additional binary copying
+  - Extract build stage artifacts via bind mount
+  - COPY the TypeScript entrypoint file and set ENTRYPOINT to direct execution via node
+  - Verify that build tools (turbo, pnpm, node-gyp, etc.) and build packages (wget, curl, etc.) are not included in the release stage
+  - _Requirements: 1.1, 3.5, 4.2, 4.5_
+
+- [x] 5.4 (P) Configure OCI labels and port/volume declarations
+  - Set OCI standard labels (source, title, description, vendor)
+  - Maintain `EXPOSE 3000` and `VOLUME /data`
+  - _Requirements: 5.1, 5.2, 5.3_
+
+- [x] 6. Integration verification and backward compatibility confirmation
+- [x] 6.1 Docker build E2E verification
+  - Execute a Docker build where all 3 stages complete successfully and confirm there are no build errors
+  - Verify that the release image does not contain a shell, apt, or build tools
+  - _Requirements: 1.1, 4.2, 4.5_
+
+- [x] 6.2 Runtime behavior and backward compatibility verification
+  - Verify that environment variables (`MONGO_URI`, `FILE_UPLOAD`, etc.) are transparently passed to the application as before
+  - Verify compatibility with `/data` volume mounts and file upload functionality
+  - Verify listening on port 3000
+  - Verify that V8 default behavior is used when memory management environment variables are not set
+  - Verify startup with `docker compose up` and graceful shutdown via SIGTERM
+  - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
+
+## Phase 2: turbo prune --docker build optimization
+
+> To be done after runtime is stable in Phase 1. Migrate from the current `COPY . .` + 3-stage structure to a `turbo prune --docker` + 5-stage structure to improve build cache efficiency.
+
+- [x] 7. Introduction of turbo prune --docker pattern
+- [x] 7.1 Create pruner stage
+  - Add a pruner stage immediately after the base stage, minimizing the monorepo for Docker with `turbo prune @growi/app @growi/pdf-converter --docker`
+  - Reason for including `@growi/pdf-converter`: `@growi/pdf-converter-client/turbo.json` has a task dependency on `@growi/pdf-converter#gen:swagger-spec`, so turbo cannot resolve task dependencies unless it is included in the pruned workspace
+  - Verified compatibility with pnpm workspace (18 packages are correctly output)
+  - Confirmed that the output (json directory, lockfile, full directory) is generated correctly
+  - _Requirements: 3.1_
+
+- [x] 7.2 Separate deps stage and restructure builder
+  - Separate dependency installation from the builder stage into an independent deps stage
+  - Copy only the package.json files and lockfile from pruner output to install dependencies (layer cache optimization)
+  - Change the builder stage to a structure that uses deps as a base and only copies source code and builds
+  - Verify that the dependency installation layer is cached when there are no dependency changes and only source code changes
+  - _Requirements: 3.1, 3.2_
+
+- [x] 7.3 Integration verification of 5-stage structure
+  - Confirm that all 5 stages (base -> pruner -> deps -> builder -> release) complete successfully
+  - Confirm that the same runtime behavior as the Phase 1 3-stage structure is maintained
+  - Verify improvement in build cache efficiency (dependency installation is skipped when only source code changes)
+  - _Requirements: 3.1, 3.2, 3.4_
+
+## Phase 3: Production replacement and CI/CD support
+
+> To be done after the 5-stage structure is stable in Phase 2. Move the artifacts from `apps/app/docker-new/` to `apps/app/docker/`, delete the old files, and update the CI/CD pipeline for DHI support.
+
+- [x] 8. Production replacement and CI/CD support
+- [x] 8.1 (P) Replace docker-new directory with docker directory
+  - Delete old files in `apps/app/docker/` (old `Dockerfile`, `docker-entrypoint.sh`, old `Dockerfile.dockerignore`)
+  - Move all files in `apps/app/docker-new/` (`Dockerfile`, `docker-entrypoint.ts`, `docker-entrypoint.spec.ts`, `Dockerfile.dockerignore`) to `apps/app/docker/`
+  - Delete the `apps/app/docker-new/` directory
+  - Confirm that the `codebuild/` directory and `README.md` are maintained within `apps/app/docker/`
+  - Update the entrypoint copy path in the Dockerfile (from `apps/app/docker-new/docker-entrypoint.ts` to `apps/app/docker/docker-entrypoint.ts`)
+  - _Requirements: 8.1, 8.2_
+
+- [x] 8.2 (P) Add DHI registry login to buildspec.yml
+  - Add a `docker login dhi.io` command to the pre_build phase of `apps/app/docker/codebuild/buildspec.yml`
+  - DHI uses Docker Hub credentials, so reuse the existing `DOCKER_REGISTRY_PASSWORD` secret and `growimoogle` username
+  - Confirm that the Dockerfile path in buildspec.yml (`./apps/app/docker/Dockerfile`) is correct after replacement
+  - _Requirements: 8.3, 8.4_
+
+- [x] 8.3 Integration verification after replacement
+  - Confirm that Docker build completes successfully with the replaced `apps/app/docker/Dockerfile`
+  - Confirm that existing external references (`codebuild.tf`, `.github/workflows/release.yml`, `ci-app.yml`, `update-readme.sh`) work correctly
+  - _Requirements: 8.1, 8.2, 8.3, 8.4_
+
+## Phase 4: Environment variable renaming and README documentation
+
+> Rename the `GROWI_`-prefixed memory management environment variables to `V8_`-prefixed names aligned with V8 option names, and add documentation to the Docker Hub README.
+
+- [x] 9. Rename environment variables to align with V8 option names
+- [x] 9.1 (P) Rename all GROWI_-prefixed environment variables to V8_-prefixed names in the entrypoint
+  - Rename `GROWI_HEAP_SIZE` to `V8_MAX_HEAP_SIZE` in the heap size detection function, validation logic, and error messages
+  - Rename `GROWI_OPTIMIZE_MEMORY` to `V8_OPTIMIZE_FOR_SIZE` in the node flag assembly function
+  - Rename `GROWI_LITE_MODE` to `V8_LITE_MODE` in the node flag assembly function
+  - Update the heap size source log message to reflect the new variable name
+  - Update the file header comment documenting the heap size detection fallback chain
+  - _Requirements: 2.1, 2.5, 2.6, 2.7, 6.1, 6.6, 6.7_
+
+- [x] 9.2 (P) Update all environment variable references in entrypoint unit tests
+  - Update heap size detection tests: replace all `GROWI_HEAP_SIZE` references with `V8_MAX_HEAP_SIZE`
+  - Update node flag assembly tests: replace `GROWI_OPTIMIZE_MEMORY` with `V8_OPTIMIZE_FOR_SIZE` and `GROWI_LITE_MODE` with `V8_LITE_MODE`
+  - Verify all tests pass with the new environment variable names
+  - _Requirements: 2.1, 2.5, 2.6_
+
+- [x] 10. Add V8 memory management environment variable documentation to README
+  - Add a subsection under Configuration > Environment Variables documenting the three V8 memory management variables
+  - Include variable name, type, default value, and description for each: `V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`
+  - Describe the 3-tier heap size fallback behavior (env var → cgroup auto-calculation → V8 default)
+  - _Requirements: 5.1_

+ 10 - 2
.kiro/steering/structure.md

@@ -4,5 +4,13 @@ See: `.claude/skills/monorepo-overview/SKILL.md` (auto-loaded by Claude Code)
 
 
 ## cc-sdd Specific Notes
 ## cc-sdd Specific Notes
 
 
-Currently, there are no additional instructions specific to Kiro.
-If instructions specific to the cc-sdd workflow are needed in the future, add them to this section.
+### Server-Client Boundary Enforcement
+
+In full-stack packages (e.g., `apps/app`), server-side code (`src/server/`, models with mongoose) must NOT be imported from client components. This causes module leakage — server-only dependencies get pulled into the client bundle.
+
+- **Pattern**: If a client component needs functionality from a server module, extract the client-safe logic into a shared utility (`src/utils/` or `src/client/util/`)
+
+For apps/app-specific examples and build tooling details, see `apps/app/.claude/skills/build-optimization/SKILL.md`.
+
+---
+_Updated: 2026-03-03. apps/app details moved to `apps/app/.claude/skills/build-optimization/SKILL.md`._

+ 53 - 2
.kiro/steering/tech.md

@@ -4,5 +4,56 @@ See: `.claude/skills/tech-stack/SKILL.md` (auto-loaded by Claude Code)
 
 
 ## cc-sdd Specific Notes
 ## cc-sdd Specific Notes
 
 
-Currently, there are no additional instructions specific to Kiro.
-If instructions specific to the cc-sdd workflow are needed in the future, add them to this section.
+### Bundler Strategy (Project-Wide Decision)
+
+GROWI uses **Turbopack** (Next.js 16 default) for **both development and production builds** (`next build` without flags). Webpack fallback is available via `USE_WEBPACK=1` environment variable for debugging only. All custom webpack loaders/plugins have been migrated to Turbopack equivalents (`turbopack.rules`, `turbopack.resolveAlias`). See `apps/app/.claude/skills/build-optimization/SKILL.md` for details.
+
+### Import Optimization Principles
+
+To prevent module count regression across the monorepo:
+
+- **Subpath imports over barrel imports** — e.g., `import { format } from 'date-fns/format'` instead of `from 'date-fns'`
+- **Lightweight replacements** — prefer small single-purpose packages over large multi-feature libraries
+- **Server-client boundary** — never import server-only code from client modules; extract client-safe utilities if needed
+
+### Turbopack Externalisation Rule (`apps/app/package.json`)
+
+**Any package that is reachable via a static `import` statement in SSR-executed code must be listed under `dependencies`, not `devDependencies`.**
+
+Turbopack externalises such packages to `.next/node_modules/` (symlinks into the pnpm store). `pnpm deploy --prod` only includes `dependencies`; packages in `devDependencies` are absent from the deploy output, causing `ERR_MODULE_NOT_FOUND` at production server startup.
+
+**SSR-executed code** = any module that Turbopack statically traces from a Pages Router page component, `_app.page.tsx`, or a server-side utility — without crossing a `dynamic(() => import(...), { ssr: false })` boundary.
+
+**Making a package devDep-eligible:**
+1. Wrap the consuming component with `dynamic(() => import('...'), { ssr: false })`, **or**
+2. Replace the runtime dependency with a static asset (e.g., extract data to a committed JSON file), **or**
+3. Change the import to a dynamic `import()` inside a `useEffect` (browser-only execution).
+
+**Packages justified to stay in `dependencies`** (SSR-reachable static imports as of v7.5):
+- `react-toastify` — `toastr.ts` static `{ toast }` import reachable from SSR pages; async refactor would break API surface
+- `bootstrap` — still externalised despite `useEffect`-guarded `import()` in `_app.page.tsx`; Turbopack traces call sites statically
+- `diff2html` — still externalised despite `ssr: false` on `RevisionDiff`; static import analysis reaches it
+- `react-dnd`, `react-dnd-html5-backend` — still externalised despite DnD provider wrapped with `ssr: false`
+- `@handsontable/react` — still externalised despite `useEffect` dynamic import in `HandsontableModal`
+- `i18next-http-backend`, `i18next-localstorage-backend`, `react-dropzone` — no direct `src/` imports but appear via transitive imports
+- `@codemirror/state`, `@headless-tree/*`, `@tanstack/react-virtual`, `downshift`, `fastest-levenshtein`, `pretty-bytes`, `react-copy-to-clipboard`, `react-hook-form`, `react-input-autosize`, `simplebar-react` — statically imported in SSR-rendered components
+
+### Production Assembly Pattern
+
+`assemble-prod.sh` produces the release artifact via **workspace-root staging** (not `apps/app/` staging):
+
+```
+pnpm deploy out --prod --legacy   → self-contained out/node_modules/ (pnpm v10)
+rm -rf node_modules
+mv out/node_modules node_modules  → workspace root is now prod-only
+ln -sfn ../../node_modules apps/app/node_modules  → compatibility symlink
+```
+
+The release image includes `node_modules/` at workspace root alongside `apps/app/`. Turbopack's `.next/node_modules/` symlinks (pointing `../../../../node_modules/.pnpm/`) resolve naturally without any sed-based rewriting. `apps/app/node_modules` is a symlink to `../../node_modules` for migration script and Node.js `require()` compatibility.
+
+**pnpm version sensitivity**: `--legacy` produces self-contained symlinks in pnpm v10+. Downgrading below v10 may break the assembly. After running `assemble-prod.sh` locally, run `pnpm install` to restore the development environment.
+
+For apps/app-specific build optimization details (webpack config, null-loader rules, SuperJSON architecture, module count KPI), see `apps/app/.claude/skills/build-optimization/SKILL.md`.
+
+---
+_Updated: 2026-03-17. Turbopack now used for production builds; expanded justified-deps list; added Production Assembly Pattern._

+ 0 - 2
.npmrc

@@ -1,2 +0,0 @@
-# see: https://pnpm.io/next/npmrc#force-legacy-deploy
-force-legacy-deploy=true

+ 1 - 2
AGENTS.md

@@ -124,8 +124,7 @@ turbo run build --filter @growi/app
 Or from apps/app directory:
 Or from apps/app directory:
 
 
 ```bash
 ```bash
-pnpm run lint:typecheck
-pnpm run lint:biome
+pnpm run lint
 pnpm run test
 pnpm run test
 pnpm run build
 pnpm run build
 ```
 ```

+ 1 - 1
README.md

@@ -81,7 +81,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 
 
 ## Dependencies
 ## Dependencies
 
 
-- Node.js v18.x or v20.x
+- Node.js v24.x
 - npm 6.x
 - npm 6.x
 - pnpm 9.x
 - pnpm 9.x
 - [Turborepo](https://turbo.build/repo)
 - [Turborepo](https://turbo.build/repo)

+ 1 - 1
README_JP.md

@@ -81,7 +81,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 
 
 ## 依存関係
 ## 依存関係
 
 
-- Node.js v18.x or v20.x
+- Node.js v24.x
 - npm 6.x
 - npm 6.x
 - pnpm 9.x
 - pnpm 9.x
 - [Turborepo](https://turbo.build/repo)
 - [Turborepo](https://turbo.build/repo)

+ 74 - 0
apps/app/.claude/rules/package-dependencies.md

@@ -0,0 +1,74 @@
+# Package Dependency Classification (Turbopack)
+
+## The Rule
+
+> Any package that appears in `apps/app/.next/node_modules/` after a production build MUST be listed under `dependencies`, not `devDependencies`.
+
+Turbopack externalises packages by generating runtime symlinks in `.next/node_modules/`. `pnpm deploy --prod` excludes `devDependencies`, so any externalised package missing from `dependencies` causes `ERR_MODULE_NOT_FOUND` in production.
+
+## How to Classify a New Package
+
+**Step 1 — Build and check:**
+
+```bash
+turbo run build --filter @growi/app
+ls apps/app/.next/node_modules/ | grep <package-name>
+```
+
+- **Found** → `dependencies`
+- **Not found** → `devDependencies` (if runtime code) or `devDependencies` (if build/test only)
+
+**Step 2 — If unsure, check the import site:**
+
+| Import pattern | Classification |
+|---|---|
+| `import foo from 'pkg'` at module level in SSR-executed code | `dependencies` |
+| `import type { Foo } from 'pkg'` only | `devDependencies` (type-erased at build) |
+| `await import('pkg')` inside `useEffect` / event handler | Check `.next/node_modules/` — may still be externalised |
+| Used only in `*.spec.ts`, build scripts, or CI | `devDependencies` |
+
+## Common Misconceptions
+
+**`dynamic({ ssr: false })` does NOT prevent Turbopack externalisation.**
+It skips HTML rendering for that component but Turbopack still externalises packages found via static import analysis inside the dynamically-loaded file.
+
+**`useEffect`-guarded `import()` does NOT guarantee devDependencies.**
+Bootstrap and i18next backends are loaded this way yet still appear in `.next/node_modules/` due to transitive imports.
+
+## Packages Confirmed as devDependencies (Verified)
+
+These were successfully removed from production artifact by eliminating their SSR import path:
+
+| Package | Technique |
+|---|---|
+| `fslightbox-react` | Replaced static import with `import()` inside `useEffect` in `LightBox.tsx` |
+| `socket.io-client` | Replaced static import with `await import()` inside `useEffect` in `admin/states/socket-io.ts` |
+| `@emoji-mart/data` | Replaced runtime import with bundled static JSON (`emoji-native-lookup.json`) |
+
+## Verifying the Production Artifact
+
+### Level 1 — Externalisation check (30–60 s, local, incremental)
+
+Just want to know if a package gets externalised by Turbopack?
+
+```bash
+turbo run build --filter @growi/app
+ls apps/app/.next/node_modules/ | grep <package-name>
+# Found → dependencies required
+# Not found → devDependencies is safe
+```
+
+Turbopack build is incremental via cache, so subsequent runs after the first are fast.
+
+### Level 2 — CI (`reusable-app-prod.yml`, authoritative)
+
+Trigger via `workflow_dispatch` before merging. Runs two jobs:
+
+1. **`build-prod`**: `turbo run build` → `assemble-prod.sh` → **`check-next-symlinks.sh`** → archives production tarball
+2. **`launch-prod`**: extracts the tarball into a clean isolated directory (no workspace-root `node_modules`), runs `pnpm run server:ci`
+
+`check-next-symlinks.sh` scans every symlink in `.next/node_modules/` and fails the build if any are broken (except `fslightbox-react` which is intentionally broken but harmless). This catches classification errors regardless of which code paths are exercised at runtime.
+
+`server:ci` = `node dist/server/app.js --ci`: the server starts fully (loading all modules), then immediately exits with code 0. If any module fails to load (`ERR_MODULE_NOT_FOUND`), the process exits with code 1, failing the CI job.
+
+This exactly matches Docker production (no workspace fallback). A `build-prod` or `launch-prod` failure definitively means a missing `dependencies` entry.

+ 16 - 2
apps/app/.claude/skills/app-commands/SKILL.md

@@ -101,10 +101,12 @@ Generated specs output to `tmp/openapi-spec-apiv3.json`.
 
 
 ```bash
 ```bash
 # Development mode
 # Development mode
-pnpm run dev:pre:styles
+pnpm run dev:pre:styles-commons
+pnpm run dev:pre:styles-components
 
 
 # Production mode
 # Production mode
-pnpm run pre:styles
+pnpm run pre:styles-commons
+pnpm run pre:styles-commons-components
 ```
 ```
 
 
 Pre-builds SCSS styles into CSS bundles using Vite.
 Pre-builds SCSS styles into CSS bundles using Vite.
@@ -140,6 +142,18 @@ pnpm run version:prerelease
 pnpm run version:preminor
 pnpm run version:preminor
 ```
 ```
 
 
+## Build Measurement
+
+```bash
+# Measure module count KPI (cleans .next, starts next dev, triggers compilation)
+./bin/measure-chunk-stats.sh           # default port 3099
+./bin/measure-chunk-stats.sh 3001      # custom port
+```
+
+Output: `[ChunkModuleStats] initial: N, async-only: N, total: N`
+
+For details on module optimization and baselines, see the `build-optimization` skill.
+
 ## Production
 ## Production
 
 
 ```bash
 ```bash

+ 127 - 0
apps/app/.claude/skills/build-optimization/SKILL.md

@@ -0,0 +1,127 @@
+---
+name: build-optimization
+description: GROWI apps/app Turbopack configuration, module optimization, and build measurement tooling. Auto-invoked when working in apps/app.
+user-invocable: false
+---
+
+# Build Optimization (apps/app)
+
+## Next.js Version & Bundler
+
+- **Next.js 16** (`^16.0.0`) with **Turbopack** bundler (default)
+- Build: `next build`; Dev: Express server calls `next({ dev })` which uses Turbopack by default
+- React stays at `^18.2.0` — Pages Router has full React 18 support in v16
+- Webpack has been fully removed (no `webpack()` hook, no `--webpack` flag)
+
+## Turbopack Configuration
+
+### Custom Loader Rules (`turbopack.rules`)
+
+| Rule | Pattern | Condition | Purpose |
+|------|---------|-----------|---------|
+| superjson-ssr-loader | `*.page.ts`, `*.page.tsx` | `{ not: 'browser' }` (server-only) | Auto-wraps `getServerSideProps` with SuperJSON serialization |
+
+- Loaders are registered in `next.config.ts` under `turbopack.rules`
+- `condition: { not: 'browser' }` restricts the loader to server-side compilation only
+- `as: '*.ts'` / `as: '*.tsx'` tells Turbopack to continue processing the transformed output as TypeScript
+
+### Resolve Aliases (`turbopack.resolveAlias`)
+
+7 server-only packages + `fs` are aliased to `./src/lib/empty-module.ts` in browser context:
+
+| Package | Reason |
+|---------|--------|
+| `fs` | Node.js built-in, not available in browser |
+| `dtrace-provider` | Native module, server-only |
+| `mongoose` | MongoDB driver, server-only |
+| `i18next-fs-backend` | File-system i18n loader, server-only |
+| `bunyan` | Server-side logger |
+| `bunyan-format` | Server-side logger formatter |
+| `core-js` | Server-side polyfills |
+
+- Uses conditional `{ browser: './src/lib/empty-module.ts' }` syntax so server-side resolution is unaffected
+- `resolveAlias` requires **relative paths** (e.g., `./src/lib/empty-module.ts`), not absolute paths — absolute paths cause "server relative imports are not implemented yet" errors
+- If a new server-only package leaks into the client bundle, add it to `resolveAlias` with the same pattern
+
+## SuperJSON Serialization Architecture
+
+The `next-superjson` SWC plugin was replaced by a custom loader:
+
+- **Build time**: `superjson-ssr-loader.ts` auto-wraps `getServerSideProps` in `.page.{ts,tsx}` files with `withSuperJSONProps()` via Turbopack `rules`
+- **Runtime (server)**: `withSuperJSONProps()` in `src/pages/utils/superjson-ssr.ts` serializes props via superjson
+- **Runtime (client)**: `_app.page.tsx` calls `deserializeSuperJSONProps()` for centralized deserialization
+- **No per-page changes needed** — new pages automatically get superjson serialization
+- Custom serializers registered in `_app.page.tsx` (ObjectId, PageRevisionWithMeta)
+
+## CSS Modules Turbopack Compatibility
+
+### `:global` Syntax
+
+Turbopack only supports the **function form** `:global(...)`. The block form `:global { ... }` is NOT supported:
+
+```scss
+// WRONG — Turbopack rejects this
+.parent :global {
+  .child { color: red; }
+}
+
+// CORRECT — function form
+.parent {
+  :global(.child) { color: red; }
+}
+```
+
+Nested blocks must also use function form:
+
+```scss
+// WRONG
+.parent :global {
+  .child {
+    .grandchild { }
+  }
+}
+
+// CORRECT
+.parent {
+  :global(.child) {
+    :global(.grandchild) { }
+  }
+}
+```
+
+### Other Turbopack CSS Restrictions
+
+- **Standalone `:local` / `&:local`**: Not supported. Inside `:global(...)`, properties are locally scoped by default — remove `&:local` wrappers
+- **`@extend` with `:global()`**: `@extend .class` fails when target is wrapped in `:global(.class)` — Sass doesn't match them as the same selector. Use shared selector groups (comma-separated selectors) instead
+- **IE CSS hacks**: `*zoom:1`, `*display:inline`, `filter:alpha()` cannot be parsed by Turbopack's CSS parser (lightningcss). Avoid CSS files containing these hacks
+
+### Vendor CSS Imports
+
+Global CSS cannot be imported from files other than `_app.page.tsx` under Turbopack Pages Router. See the `vendor-styles-components` skill for the precompilation system that handles per-component vendor CSS.
+
+## Module Optimization Configuration
+
+- `serverExternalPackages: ['handsontable']` — packages excluded from server-side bundling
+- `optimizePackageImports` — 11 `@growi/*` packages configured (expansion to third-party packages was tested and reverted — it increased dev module count)
+
+## Effective Module Reduction Techniques
+
+Techniques that have proven effective for reducing module count, ordered by typical impact:
+
+| Technique | When to Use |
+|-----------|-------------|
+| `next/dynamic({ ssr: true })` | Heavy rendering pipelines (markdown, code highlighting) that can be deferred to async chunks while preserving SSR |
+| `next/dynamic({ ssr: false })` | Client-only heavy components (e.g., Mermaid diagrams, interactive editors) |
+| Subpath imports | Packages with large barrel exports (e.g., `date-fns/format` instead of `date-fns`) |
+| Deep ESM imports | Packages that re-export multiple engines via barrel (e.g., `react-syntax-highlighter/dist/esm/prism-async-light`) |
+| resolveAlias | Server-only packages leaking into client bundle via transitive imports |
+| Lightweight replacements | Replace large libraries used for a single feature (e.g., `tinykeys` instead of `react-hotkeys`, regex instead of `validator`) |
+
+### Techniques That Did NOT Work
+
+- **Expanding `optimizePackageImports` to third-party packages** — In dev mode, this resolves individual sub-module files instead of barrel, resulting in MORE module entries. Reverted.
+- **Refactoring internal barrel exports** — Internal barrels (`states/`, `features/`) are small and well-scoped; refactoring had no measurable impact.
+
+## i18n HMR
+
+`I18NextHMRPlugin` was removed during the Turbopack migration. Translation file changes require a manual browser refresh. The performance gain from Turbopack (faster Fast Refresh overall) outweighs the loss of i18n-specific HMR. Monitor if `i18next-hmr` adds Turbopack support in the future.

+ 116 - 0
apps/app/.claude/skills/vendor-styles-components/SKILL.md

@@ -0,0 +1,116 @@
+---
+name: vendor-styles-components
+description: Vendor CSS precompilation system for Turbopack compatibility. How to add third-party CSS to components without violating Pages Router global CSS restriction. Auto-invoked when working in apps/app.
+---
+
+# Vendor CSS Precompilation (apps/app)
+
+## Problem
+
+Turbopack (Pages Router) strictly enforces: **global CSS can only be imported from `_app.page.tsx`**. Components cannot `import 'package/style.css'` directly — Turbopack rejects these at compile time.
+
+Centralizing all vendor CSS in `_app` would degrade FCP for pages that don't need those styles.
+
+## Solution: Two-Track Vendor CSS System
+
+### Commons Track (globally shared CSS)
+
+- **File**: `src/styles/vendor.scss`
+- **For**: CSS needed on most pages (e.g., `simplebar-react`)
+- **Mechanism**: Compiled via `vite.vendor-styles-commons.ts` into `src/styles/prebuilt/`
+- **Imported from**: `_app.page.tsx`
+
+### Components Track (component-specific CSS)
+
+- **For**: CSS needed only by specific components
+- **Mechanism**: Vite precompiles `*.vendor-styles.ts` entry points into `*.vendor-styles.prebuilt.js` using `?inline` CSS import suffix
+- **Output**: Pure JS modules (no CSS imports) — Turbopack sees them as regular JS
+
+## How It Works
+
+1. **Entry point** (`ComponentName.vendor-styles.ts`): imports CSS via Vite `?inline` suffix, which inlines CSS as a string
+2. **Runtime injection**: the entry point creates a `<style>` tag and appends CSS to `document.head`
+3. **Vite prebuild** (`pre:styles-components` Turborepo task): compiles entry points into `*.vendor-styles.prebuilt.js`
+4. **Component import**: imports the `.prebuilt.js` file instead of raw CSS
+
+### Entry Point Template
+
+```typescript
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from 'some-package/dist/style.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);
+```
+
+For multiple CSS sources in one component:
+
+```typescript
+// @ts-nocheck
+import css1 from 'package-a/style.css?inline';
+import css2 from 'package-b/style.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css1 + css2;
+document.head.appendChild(s);
+```
+
+## Current Entry Points
+
+| Entry Point | CSS Sources | Consuming Components |
+|---|---|---|
+| `Renderer.vendor-styles.ts` | `@growi/remark-lsx`, `@growi/remark-attachment-refs`, `katex` | renderer.tsx |
+| `GrowiEditor.vendor-styles.ts` | `@growi/editor` | PageEditor, CommentEditor |
+| `HandsontableModal.vendor-styles.ts` | `handsontable` (non-full variant) | HandsontableModal |
+| `DateRangePicker.vendor-styles.ts` | `react-datepicker` | DateRangePicker |
+| `RevisionDiff.vendor-styles.ts` | `diff2html` | RevisionDiff |
+| `DrawioViewerWithEditButton.vendor-styles.ts` | `@growi/remark-drawio` | DrawioViewerWithEditButton |
+| `ImageCropModal.vendor-styles.ts` | `react-image-crop` | ImageCropModal |
+| `Presentation.vendor-styles.ts` | `@growi/presentation` | Presentation, Slides |
+
+## Adding New Vendor CSS
+
+1. Create `{ComponentName}.vendor-styles.ts` next to the consuming component:
+   ```typescript
+   // @ts-nocheck
+   import css from 'new-package/dist/style.css?inline';
+   const s = document.createElement('style');
+   s.textContent = css;
+   document.head.appendChild(s);
+   ```
+2. In the component, replace `import 'new-package/dist/style.css'` with:
+   ```typescript
+   import './ComponentName.vendor-styles.prebuilt';
+   ```
+3. Run `pnpm run pre:styles-components` (or let Turborepo handle it during `dev`/`build`)
+4. The `.prebuilt.js` file is git-ignored and auto-generated
+
+**Decision guide**: If the CSS is needed on nearly every page, add it to the commons track (`vendor.scss`) instead.
+
+## Font/Asset Handling
+
+When vendor CSS references external assets (e.g., KaTeX `@font-face` with `url(fonts/KaTeX_*.woff2)`):
+
+- Vite emits asset files to `src/assets/` during build
+- The `moveAssetsToPublic` plugin (in `vite.vendor-styles-components.ts`) relocates them to `public/static/fonts/`
+- URL references in prebuilt JS are rewritten from `/assets/` to `/static/fonts/`
+- Fonts are served by the existing `express.static(crowi.publicDir)` middleware
+- Both `public/static/fonts/` and `src/**/*.vendor-styles.prebuilt.js` are git-ignored
+
+## Build Pipeline Integration
+
+```
+turbo.json tasks:
+  pre:styles-components  →  build (dependency)
+  dev:pre:styles-components  →  dev (dependency)
+
+Inputs:  vite.vendor-styles-components.ts, src/**/*.vendor-styles.ts, package.json
+Outputs: src/**/*.vendor-styles.prebuilt.js, public/static/fonts/**
+```
+
+## Important Caveats
+
+- **SSR**: CSS is injected via `<style>` tags at runtime — not available during SSR. Most consuming components use `next/dynamic({ ssr: false })`, so FOUC is not a practical concern
+- **`@ts-nocheck`**: Required because `?inline` is a Vite-specific import suffix not understood by TypeScript
+- **handsontable**: Must use `handsontable/dist/handsontable.css` (non-full, non-minified). The "full" variant (`handsontable.full.min.css`) contains IE CSS hacks (`*zoom:1`, `filter:alpha()`) that Turbopack's CSS parser (lightningcss) cannot parse. The "full" variant also includes Pikaday which is unused.

+ 2 - 0
apps/app/.env.development

@@ -4,6 +4,8 @@
 ##
 ##
 MIGRATIONS_DIR=src/migrations/
 MIGRATIONS_DIR=src/migrations/
 
 
+NEXT_TELEMETRY_DISABLED=1
+
 APP_SITE_URL=http://localhost:3000
 APP_SITE_URL=http://localhost:3000
 FILE_UPLOAD=mongodb
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760

+ 4 - 0
apps/app/.gitignore

@@ -1,6 +1,8 @@
 # next.js
 # next.js
 /.next/
 /.next/
 /out/
 /out/
+next-env.d.ts
+next.config.js
 
 
 # test
 # test
 .reg
 .reg
@@ -9,10 +11,12 @@
 /build/
 /build/
 /dist/
 /dist/
 /transpiled/
 /transpiled/
+/public/static/fonts
 /public/static/js
 /public/static/js
 /public/static/styles
 /public/static/styles
 /public/uploads
 /public/uploads
 /src/styles/prebuilt
 /src/styles/prebuilt
+/src/**/*.vendor-styles.prebuilt.js
 /tmp/
 /tmp/
 
 
 # cache
 # cache

+ 8 - 0
apps/app/AGENTS.md

@@ -159,3 +159,11 @@ Plus all global skills (monorepo-overview, tech-stack).
 ---
 ---
 
 
 For detailed patterns and examples, refer to the Skills in `.claude/skills/`.
 For detailed patterns and examples, refer to the Skills in `.claude/skills/`.
+
+## Rules (Always Applied)
+
+The following rules in `.claude/rules/` are always applied when working in this directory:
+
+| Rule | Description |
+|------|-------------|
+| **package-dependencies** | Turbopack dependency classification — when to use `dependencies` vs `devDependencies`, verification procedure |

+ 30 - 0
apps/app/bin/assemble-prod.sh

@@ -0,0 +1,30 @@
+#!/bin/bash
+# Assemble production artifacts for GROWI app.
+# Run from the workspace root.
+set -euo pipefail
+
+echo "[1/4] Collecting production dependencies..."
+rm -rf out
+pnpm deploy out --prod --legacy --filter @growi/app
+echo "[1/4] Done."
+
+echo "[2/4] Reorganizing node_modules..."
+rm -rf node_modules
+mv out/node_modules node_modules
+rm -rf apps/app/node_modules
+ln -sfn ../../node_modules apps/app/node_modules
+rm -rf out
+echo "[2/4] Done."
+
+echo "[3/4] Removing build cache..."
+rm -rf apps/app/.next/cache
+echo "[3/4] Done."
+
+# Provide a CJS runtime config so the production server can load it without TypeScript.
+# next.config.js takes precedence over next.config.ts in Next.js, so the .ts file
+# is left in place but effectively ignored at runtime.
+echo "[4/4] Installing runtime next.config.js..."
+cp apps/app/next.config.prod.cjs apps/app/next.config.js
+echo "[4/4] Done."
+
+echo "Assembly complete."

+ 36 - 0
apps/app/bin/check-next-symlinks.sh

@@ -0,0 +1,36 @@
+#!/bin/bash
+# Check that all .next/node_modules/ symlinks resolve correctly after assemble-prod.sh.
+# Usage: bash apps/app/bin/check-next-symlinks.sh (from monorepo root)
+set -euo pipefail
+
+NEXT_MODULES="apps/app/.next/node_modules"
+
+# Packages that are intentionally broken symlinks.
+# These are only imported via useEffect + dynamic import() and never accessed during SSR.
+ALLOWED_BROKEN=(
+  fslightbox-react
+  @emoji-mart/data
+  @emoji-mart/react
+)
+
+# Build a grep -v pattern from the allowlist
+grep_args=()
+for pkg in "${ALLOWED_BROKEN[@]}"; do
+  grep_args+=(-e "$pkg")
+done
+
+broken=$(find "$NEXT_MODULES" -maxdepth 2 -type l | while read -r link; do
+  linkdir=$(dirname "$link")
+  target=$(readlink "$link")
+  resolved=$(cd "$linkdir" 2>/dev/null && realpath -m "$target" 2>/dev/null || echo "UNRESOLVABLE")
+  { [ "$resolved" = "UNRESOLVABLE" ] || [ ! -e "$resolved" ]; } && echo "BROKEN: $link"
+done | grep -v "${grep_args[@]}" || true)
+
+if [ -n "$broken" ]; then
+  echo "ERROR: Broken symlinks found in $NEXT_MODULES:"
+  echo "$broken"
+  echo "Move these packages from devDependencies to dependencies in apps/app/package.json."
+  exit 1
+fi
+
+echo "OK: All $NEXT_MODULES symlinks resolve correctly."

+ 60 - 0
apps/app/bin/measure-chunk-stats.sh

@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# Measure ChunkModuleStats (initial / async-only / total) for [[...path]] page.
+# Usage: ./bin/measure-chunk-stats.sh [port]
+set -euo pipefail
+
+PORT="${1:-3099}"
+LOG=$(mktemp /tmp/chunk-stats-XXXXXX.log)
+
+cleanup() {
+  local pids
+  pids=$(lsof -ti :"$PORT" 2>/dev/null || true)
+  if [ -n "$pids" ]; then
+    kill -9 $pids 2>/dev/null || true
+  fi
+  rm -f "$LOG"
+}
+trap cleanup EXIT
+
+# 1. Ensure port is free
+cleanup_pids=$(lsof -ti :"$PORT" 2>/dev/null || true)
+if [ -n "$cleanup_pids" ]; then
+  kill -9 $cleanup_pids 2>/dev/null || true
+  sleep 1
+fi
+
+# 2. Clean .next dev cache (v16 uses .next/dev for isolated dev builds)
+rm -rf "$(dirname "$0")/../.next/dev"
+
+# 3. Start Next.js dev server (--webpack to opt out of Turbopack default in v16)
+cd "$(dirname "$0")/.."
+npx next dev --webpack -p "$PORT" > "$LOG" 2>&1 &
+NEXT_PID=$!
+
+# 4. Wait for server ready
+echo "Waiting for Next.js to start on port $PORT ..."
+for i in $(seq 1 30); do
+  if grep -q "Local:" "$LOG" 2>/dev/null; then
+    break
+  fi
+  sleep 1
+done
+
+# 5. Trigger compilation
+echo "Triggering compilation ..."
+curl -s -o /dev/null http://localhost:"$PORT"/
+
+# 6. Wait for ChunkModuleStats output (non-zero initial)
+echo "Waiting for compilation ..."
+for i in $(seq 1 120); do
+  if grep -qP 'ChunkModuleStats\] initial: [1-9]' "$LOG" 2>/dev/null; then
+    break
+  fi
+  sleep 2
+done
+
+# 7. Print results
+echo ""
+echo "=== Results ==="
+grep -E 'ChunkModuleStats|Compiled.*modules' "$LOG" | grep -v 'initial: 0,' | head -5
+echo ""

+ 2 - 8
apps/app/config/next-i18next.config.js

@@ -1,5 +1,4 @@
 const isDev = process.env.NODE_ENV === 'development';
 const isDev = process.env.NODE_ENV === 'development';
-
 // biome-ignore lint/style/useNodejsImportProtocol: ignore
 // biome-ignore lint/style/useNodejsImportProtocol: ignore
 const path = require('path');
 const path = require('path');
 
 
@@ -8,8 +7,6 @@ const { isServer } = require('@growi/core/dist/utils');
 
 
 const { defaultLang } = require('./i18next.config');
 const { defaultLang } = require('./i18next.config');
 
 
-const HMRPlugin = isDev ? require('i18next-hmr/plugin').HMRPlugin : undefined;
-
 /** @type {import('next-i18next').UserConfig} */
 /** @type {import('next-i18next').UserConfig} */
 module.exports = {
 module.exports = {
   ...require('./i18next.config').initOptions,
   ...require('./i18next.config').initOptions,
@@ -24,11 +21,8 @@ module.exports = {
 
 
   use: isDev
   use: isDev
     ? isServer()
     ? isServer()
-      ? [new HMRPlugin({ webpack: { server: true } })]
-      : [
-          require('i18next-chained-backend').default,
-          new HMRPlugin({ webpack: { client: true } }),
-        ]
+      ? []
+      : [require('i18next-chained-backend').default]
     : [],
     : [],
   backend: {
   backend: {
     backends: isServer()
     backends: isServer()

+ 79 - 51
apps/app/docker/Dockerfile

@@ -1,110 +1,138 @@
-# syntax = docker/dockerfile:1.4
+# syntax=docker/dockerfile:1
 
 
+ARG NODE_VERSION=24
 ARG OPT_DIR="/opt"
 ARG OPT_DIR="/opt"
 ARG PNPM_HOME="/root/.local/share/pnpm"
 ARG PNPM_HOME="/root/.local/share/pnpm"
 
 
 ##
 ##
-## base
+## base — DHI dev image with pnpm + turbo
 ##
 ##
-FROM node:20-slim AS base
+FROM dhi.io/node:24-debian13-dev AS base
 
 
 ARG OPT_DIR
 ARG OPT_DIR
 ARG PNPM_HOME
 ARG PNPM_HOME
 
 
 WORKDIR $OPT_DIR
 WORKDIR $OPT_DIR
 
 
-# install tools
+# Install build dependencies
 RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
 RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
     --mount=type=cache,target=/var/cache/apt,sharing=locked \
     --mount=type=cache,target=/var/cache/apt,sharing=locked \
-  apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
+  apt-get update && apt-get install -y --no-install-recommends ca-certificates wget
 
 
-# install pnpm
-RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.4.1" sh -
+# Install pnpm (standalone script, no version hardcoding)
+RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL=/bin/sh sh -
 ENV PNPM_HOME=$PNPM_HOME
 ENV PNPM_HOME=$PNPM_HOME
 ENV PATH="$PNPM_HOME:$PATH"
 ENV PATH="$PNPM_HOME:$PATH"
 
 
-# install turbo
+# Install turbo globally
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
   pnpm add turbo --global
   pnpm add turbo --global
 
 
 
 
+##
+## pruner — turbo prune for Docker-optimized monorepo subset
+##
+FROM base AS pruner
+
+ARG OPT_DIR
+
+WORKDIR $OPT_DIR
+
+COPY . .
+
+# Include @growi/pdf-converter because @growi/pdf-converter-client has a turbo
+# task dependency on @growi/pdf-converter#gen:swagger-spec (generates the OpenAPI
+# spec that orval uses to build the client). Without it, turbo cannot resolve
+# the cross-package task dependency in the pruned workspace.
+RUN turbo prune @growi/app @growi/pdf-converter --docker
+
 
 
 ##
 ##
-## builder
+## deps — dependency installation (layer cached when only source changes)
 ##
 ##
-FROM base AS builder
+FROM base AS deps
+
+ARG OPT_DIR
+ARG PNPM_HOME
 
 
 ENV PNPM_HOME=$PNPM_HOME
 ENV PNPM_HOME=$PNPM_HOME
 ENV PATH="$PNPM_HOME:$PATH"
 ENV PATH="$PNPM_HOME:$PATH"
 
 
 WORKDIR $OPT_DIR
 WORKDIR $OPT_DIR
 
 
-COPY . .
+# Copy only package manifests and lockfile for dependency caching
+COPY --from=pruner $OPT_DIR/out/json/ .
 
 
+# Install build tools and dependencies
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
   pnpm add node-gyp --global
   pnpm add node-gyp --global
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
-  pnpm install ---frozen-lockfile
+  pnpm install --frozen-lockfile
 
 
-# build
+
+##
+## builder — build + produce artifacts
+##
+FROM deps AS builder
+
+ARG OPT_DIR
+
+WORKDIR $OPT_DIR
+
+# Copy full source on top of installed dependencies
+COPY --from=pruner $OPT_DIR/out/full/ .
+
+# turbo prune does not include root-level config files in its output.
+# tsconfig.base.json is referenced by most packages via "extends": "../../tsconfig.base.json"
+COPY tsconfig.base.json .
+
+# Build
 RUN turbo run clean
 RUN turbo run clean
 RUN turbo run build --filter @growi/app
 RUN turbo run build --filter @growi/app
 
 
-# make artifacts
-RUN pnpm deploy out --prod --filter @growi/app
-RUN rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
-RUN rm -rf apps/app/.next/cache
-RUN tar -zcf /tmp/packages.tar.gz \
-  package.json \
-  apps/app/.next \
-  apps/app/config \
-  apps/app/dist \
-  apps/app/public \
-  apps/app/resource \
-  apps/app/tmp \
-  apps/app/.env.production* \
-  apps/app/next.config.js \
-  apps/app/package.json \
-  apps/app/node_modules
+# Produce artifacts
+RUN bash apps/app/bin/assemble-prod.sh
 
 
+# Stage artifacts into a clean directory for COPY --from
+RUN mkdir -p /tmp/release/apps/app && \
+  cp package.json /tmp/release/ && \
+  cp -a node_modules /tmp/release/ && \
+  cp -a apps/app/.next apps/app/config apps/app/dist apps/app/public \
+       apps/app/resource apps/app/tmp \
+       apps/app/package.json apps/app/node_modules \
+       apps/app/next.config.js \
+       /tmp/release/apps/app/ && \
+  (cp apps/app/.env.production* /tmp/release/apps/app/ 2>/dev/null || true)
 
 
 
 
 ##
 ##
-## release
+## release — DHI runtime (no shell, no additional binaries)
 ##
 ##
-FROM node:20-slim
-LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
+FROM dhi.io/node:24-debian13 AS release
 
 
 ARG OPT_DIR
 ARG OPT_DIR
 
 
 ENV NODE_ENV="production"
 ENV NODE_ENV="production"
-
 ENV appDir="$OPT_DIR/growi"
 ENV appDir="$OPT_DIR/growi"
 
 
-# Add gosu
-# see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
-RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
-    --mount=type=cache,target=/var/cache/apt,sharing=locked \
-  set -eux; \
-	apt-get update; \
-	apt-get install -y gosu; \
-	rm -rf /var/lib/apt/lists/*; \
-# verify that the binary works
-	gosu nobody true
-
-# extract artifacts as 'node' user
-USER node
+# Copy artifacts from builder (no shell required)
 WORKDIR ${appDir}
 WORKDIR ${appDir}
-RUN --mount=type=bind,from=builder,source=/tmp/packages.tar.gz,target=/tmp/packages.tar.gz \
-  tar -zxf /tmp/packages.tar.gz -C ${appDir}/
+COPY --from=builder --chown=node:node /tmp/release/ ${appDir}/
 
 
-COPY --chown=node:node --chmod=700 apps/app/docker/docker-entrypoint.sh /
+# Copy TypeScript entrypoint
+COPY --chown=node:node apps/app/docker/docker-entrypoint.ts /docker-entrypoint.ts
 
 
+# Switch back to root for entrypoint (it handles privilege drop)
 USER root
 USER root
 WORKDIR ${appDir}/apps/app
 WORKDIR ${appDir}/apps/app
 
 
+# OCI standard labels
+LABEL org.opencontainers.image.source="https://github.com/weseek/growi"
+LABEL org.opencontainers.image.title="GROWI"
+LABEL org.opencontainers.image.description="Team collaboration wiki using Markdown"
+LABEL org.opencontainers.image.vendor="WESEEK, Inc."
+
 VOLUME /data
 VOLUME /data
 EXPOSE 3000
 EXPOSE 3000
 
 
-ENTRYPOINT ["/docker-entrypoint.sh"]
-CMD ["npm run migrate && node -r dotenv-flow/config --expose_gc dist/server/app.js"]
+ENTRYPOINT ["node", "/docker-entrypoint.ts"]

+ 74 - 4
apps/app/docker/Dockerfile.dockerignore

@@ -1,9 +1,79 @@
+# ============================================================
+# Build artifacts and caches
+# ============================================================
 **/node_modules
 **/node_modules
-**/coverage
-**/Dockerfile
-**/*.dockerignore
-**/.pnpm-store
 **/.next
 **/.next
 **/.turbo
 **/.turbo
+**/.pnpm-store
+**/coverage
 out
 out
+
+# ============================================================
+# Version control
+# ============================================================
+.git
+
+# ============================================================
+# Docker files (prevent recursive inclusion)
+# ============================================================
+**/Dockerfile
+**/*.dockerignore
+
+# ============================================================
+# Unrelated apps
+# ============================================================
 apps/slackbot-proxy
 apps/slackbot-proxy
+
+# ============================================================
+# Test files
+# ============================================================
+**/*.spec.*
+**/*.test.*
+**/test/
+**/__tests__/
+**/playwright/
+
+# ============================================================
+# Documentation (no .md files are needed for build)
+# ============================================================
+**/*.md
+
+# ============================================================
+# Local environment overrides
+# ============================================================
+.env.local
+.env.*.local
+
+# ============================================================
+# IDE and editor settings
+# ============================================================
+.vscode
+.idea
+**/.DS_Store
+
+# ============================================================
+# CI/CD, DevOps, and project management
+# ============================================================
+.changeset
+.devcontainer
+.github
+aws
+bin
+
+# ============================================================
+# Linter, formatter, and tool configs (not needed for build)
+# ============================================================
+**/.editorconfig
+**/.markdownlint.yml
+**/.prettier*
+**/.stylelintrc*
+**/biome.json
+**/lefthook.yml
+
+# ============================================================
+# AI agent configuration
+# ============================================================
+**/.claude
+**/.kiro
+**/.mcp.json
+**/.serena

+ 10 - 0
apps/app/docker/README.md

@@ -72,6 +72,16 @@ See [GROWI Docs: Admin Guide](https://docs.growi.org/en/admin-guide/) ([en](http
 
 
 - [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/admin-cookbook/env-vars.html)
 - [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/admin-cookbook/env-vars.html)
 
 
+#### V8 Memory Management
+
+| Variable | Type | Default | Description |
+|----------|------|---------|-------------|
+| `V8_MAX_HEAP_SIZE` | int (MB) | (unset) | Explicitly specify the `--max-heap-size` value for Node.js |
+| `V8_OPTIMIZE_FOR_SIZE` | `"true"` / (unset) | (unset) | Enable the `--optimize-for-size` V8 flag to reduce memory usage |
+| `V8_LITE_MODE` | `"true"` / (unset) | (unset) | Enable the `--lite-mode` V8 flag to reduce memory usage at the cost of performance |
+
+**Heap size fallback behavior**: When `V8_MAX_HEAP_SIZE` is not set, the entrypoint automatically detects the container's memory limit via cgroup (v2/v1) and sets the heap size to 60% of the limit. If no cgroup limit is detected, V8's default heap behavior is used.
+
 
 
 Issues
 Issues
 ------
 ------

+ 2 - 0
apps/app/docker/codebuild/buildspec.yml

@@ -12,6 +12,8 @@ phases:
     commands:
     commands:
       # login to docker.io
       # login to docker.io
       - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username growimoogle --password-stdin
       - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username growimoogle --password-stdin
+      # login to dhi.io (DHI uses Docker Hub credentials)
+      - echo ${DOCKER_REGISTRY_PASSWORD} | docker login dhi.io --username growimoogle --password-stdin
   build:
   build:
     commands:
     commands:
       - docker build -t ${IMAGE_TAG} -f ./apps/app/docker/Dockerfile .
       - docker build -t ${IMAGE_TAG} -f ./apps/app/docker/Dockerfile .

+ 0 - 18
apps/app/docker/docker-entrypoint.sh

@@ -1,18 +0,0 @@
-#!/bin/sh
-
-set -e
-
-# Support `FILE_UPLOAD=local`
-mkdir -p /data/uploads
-if [ ! -e "./public/uploads" ]; then
-  ln -s /data/uploads ./public/uploads
-fi
-chown -R node:node /data/uploads
-chown -h node:node ./public/uploads
-
-# Set permissions for shared directory for bulk export
-mkdir -p /tmp/page-bulk-export
-chown -R node:node /tmp/page-bulk-export
-chmod 700 /tmp/page-bulk-export
-
-exec gosu node /bin/bash -c "$@"

+ 358 - 0
apps/app/docker/docker-entrypoint.spec.ts

@@ -0,0 +1,358 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+  buildNodeFlags,
+  chownRecursive,
+  detectHeapSize,
+  readCgroupLimit,
+  setupDirectories,
+} from './docker-entrypoint';
+
+describe('chownRecursive', () => {
+  let tmpDir: string;
+
+  beforeEach(() => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'entrypoint-test-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  it('should chown a flat directory', () => {
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    chownRecursive(tmpDir, 1000, 1000);
+    // Should chown the directory itself
+    expect(chownSyncSpy).toHaveBeenCalledWith(tmpDir, 1000, 1000);
+    chownSyncSpy.mockRestore();
+  });
+
+  it('should chown nested directories and files recursively', () => {
+    // Create nested structure
+    const subDir = path.join(tmpDir, 'sub');
+    fs.mkdirSync(subDir);
+    fs.writeFileSync(path.join(tmpDir, 'file1.txt'), 'hello');
+    fs.writeFileSync(path.join(subDir, 'file2.txt'), 'world');
+
+    const chownedPaths: string[] = [];
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation((p) => {
+      chownedPaths.push(p as string);
+    });
+
+    chownRecursive(tmpDir, 1000, 1000);
+
+    expect(chownedPaths).toContain(tmpDir);
+    expect(chownedPaths).toContain(subDir);
+    expect(chownedPaths).toContain(path.join(tmpDir, 'file1.txt'));
+    expect(chownedPaths).toContain(path.join(subDir, 'file2.txt'));
+    expect(chownedPaths).toHaveLength(4);
+
+    chownSyncSpy.mockRestore();
+  });
+
+  it('should handle empty directory', () => {
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    chownRecursive(tmpDir, 1000, 1000);
+    // Should only chown the directory itself
+    expect(chownSyncSpy).toHaveBeenCalledTimes(1);
+    expect(chownSyncSpy).toHaveBeenCalledWith(tmpDir, 1000, 1000);
+    chownSyncSpy.mockRestore();
+  });
+});
+
+describe('readCgroupLimit', () => {
+  it('should read cgroup v2 numeric limit', () => {
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockReturnValue('1073741824\n');
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBe(1073741824);
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for cgroup v2 "max" (unlimited)', () => {
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('max\n');
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined when file does not exist', () => {
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for NaN content', () => {
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('invalid\n');
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+});
+
+describe('detectHeapSize', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    process.env = { ...originalEnv };
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  it('should use V8_MAX_HEAP_SIZE when set', () => {
+    process.env.V8_MAX_HEAP_SIZE = '512';
+    const readSpy = vi.spyOn(fs, 'readFileSync');
+    const result = detectHeapSize();
+    expect(result).toBe(512);
+    // Should not attempt to read cgroup files
+    expect(readSpy).not.toHaveBeenCalled();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for invalid V8_MAX_HEAP_SIZE', () => {
+    process.env.V8_MAX_HEAP_SIZE = 'abc';
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for empty V8_MAX_HEAP_SIZE', () => {
+    process.env.V8_MAX_HEAP_SIZE = '';
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should auto-calculate from cgroup v2 at 60%', () => {
+    delete process.env.V8_MAX_HEAP_SIZE;
+    // 1GB = 1073741824 bytes → 60% ≈ 614 MB
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockImplementation((filePath) => {
+        if (filePath === '/sys/fs/cgroup/memory.max') return '1073741824\n';
+        throw new Error('ENOENT');
+      });
+    const result = detectHeapSize();
+    expect(result).toBe(Math.floor((1073741824 / 1024 / 1024) * 0.6));
+    readSpy.mockRestore();
+  });
+
+  it('should fallback to cgroup v1 when v2 is unlimited', () => {
+    delete process.env.V8_MAX_HEAP_SIZE;
+    // v2 = max (unlimited), v1 = 2GB
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockImplementation((filePath) => {
+        if (filePath === '/sys/fs/cgroup/memory.max') return 'max\n';
+        if (filePath === '/sys/fs/cgroup/memory/memory.limit_in_bytes')
+          return '2147483648\n';
+        throw new Error('ENOENT');
+      });
+    const result = detectHeapSize();
+    expect(result).toBe(Math.floor((2147483648 / 1024 / 1024) * 0.6));
+    readSpy.mockRestore();
+  });
+
+  it('should treat cgroup v1 > 64GB as unlimited', () => {
+    delete process.env.V8_MAX_HEAP_SIZE;
+    const hugeValue = 128 * 1024 * 1024 * 1024; // 128GB
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockImplementation((filePath) => {
+        if (filePath === '/sys/fs/cgroup/memory.max') return 'max\n';
+        if (filePath === '/sys/fs/cgroup/memory/memory.limit_in_bytes')
+          return `${hugeValue}\n`;
+        throw new Error('ENOENT');
+      });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined when no cgroup limits detected', () => {
+    delete process.env.V8_MAX_HEAP_SIZE;
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should prioritize V8_MAX_HEAP_SIZE over cgroup', () => {
+    process.env.V8_MAX_HEAP_SIZE = '256';
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockReturnValue('1073741824\n');
+    const result = detectHeapSize();
+    expect(result).toBe(256);
+    // Should not have read cgroup files
+    expect(readSpy).not.toHaveBeenCalled();
+    readSpy.mockRestore();
+  });
+});
+
+describe('buildNodeFlags', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    process.env = { ...originalEnv };
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  it('should always include --expose_gc', () => {
+    const flags = buildNodeFlags(undefined);
+    expect(flags).toContain('--expose_gc');
+  });
+
+  it('should include --max-heap-size when heapSize is provided', () => {
+    const flags = buildNodeFlags(512);
+    expect(flags).toContain('--max-heap-size=512');
+  });
+
+  it('should not include --max-heap-size when heapSize is undefined', () => {
+    const flags = buildNodeFlags(undefined);
+    expect(flags.some((f) => f.startsWith('--max-heap-size'))).toBe(false);
+  });
+
+  it('should include --optimize-for-size when V8_OPTIMIZE_FOR_SIZE=true', () => {
+    process.env.V8_OPTIMIZE_FOR_SIZE = 'true';
+    const flags = buildNodeFlags(undefined);
+    expect(flags).toContain('--optimize-for-size');
+  });
+
+  it('should not include --optimize-for-size when V8_OPTIMIZE_FOR_SIZE is not true', () => {
+    process.env.V8_OPTIMIZE_FOR_SIZE = 'false';
+    const flags = buildNodeFlags(undefined);
+    expect(flags).not.toContain('--optimize-for-size');
+  });
+
+  it('should include --lite-mode when V8_LITE_MODE=true', () => {
+    process.env.V8_LITE_MODE = 'true';
+    const flags = buildNodeFlags(undefined);
+    expect(flags).toContain('--lite-mode');
+  });
+
+  it('should not include --lite-mode when V8_LITE_MODE is not true', () => {
+    delete process.env.V8_LITE_MODE;
+    const flags = buildNodeFlags(undefined);
+    expect(flags).not.toContain('--lite-mode');
+  });
+
+  it('should combine all flags when all options enabled', () => {
+    process.env.V8_OPTIMIZE_FOR_SIZE = 'true';
+    process.env.V8_LITE_MODE = 'true';
+    const flags = buildNodeFlags(256);
+    expect(flags).toContain('--expose_gc');
+    expect(flags).toContain('--max-heap-size=256');
+    expect(flags).toContain('--optimize-for-size');
+    expect(flags).toContain('--lite-mode');
+  });
+
+  it('should not use --max_old_space_size', () => {
+    const flags = buildNodeFlags(512);
+    expect(flags.some((f) => f.includes('max_old_space_size'))).toBe(false);
+  });
+});
+
+describe('setupDirectories', () => {
+  let tmpDir: string;
+
+  beforeEach(() => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'entrypoint-setup-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  it('should create uploads directory and symlink', () => {
+    const uploadsDir = path.join(tmpDir, 'data', 'uploads');
+    const publicUploads = path.join(tmpDir, 'public', 'uploads');
+    fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
+
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    const lchownSyncSpy = vi
+      .spyOn(fs, 'lchownSync')
+      .mockImplementation(() => {});
+
+    setupDirectories(
+      uploadsDir,
+      publicUploads,
+      path.join(tmpDir, 'bulk-export'),
+    );
+
+    expect(fs.existsSync(uploadsDir)).toBe(true);
+    expect(fs.lstatSync(publicUploads).isSymbolicLink()).toBe(true);
+    expect(fs.readlinkSync(publicUploads)).toBe(uploadsDir);
+
+    chownSyncSpy.mockRestore();
+    lchownSyncSpy.mockRestore();
+  });
+
+  it('should not recreate symlink if it already exists', () => {
+    const uploadsDir = path.join(tmpDir, 'data', 'uploads');
+    const publicUploads = path.join(tmpDir, 'public', 'uploads');
+    fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
+    fs.mkdirSync(uploadsDir, { recursive: true });
+    fs.symlinkSync(uploadsDir, publicUploads);
+
+    const symlinkSpy = vi.spyOn(fs, 'symlinkSync');
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    const lchownSyncSpy = vi
+      .spyOn(fs, 'lchownSync')
+      .mockImplementation(() => {});
+
+    setupDirectories(
+      uploadsDir,
+      publicUploads,
+      path.join(tmpDir, 'bulk-export'),
+    );
+
+    expect(symlinkSpy).not.toHaveBeenCalled();
+
+    symlinkSpy.mockRestore();
+    chownSyncSpy.mockRestore();
+    lchownSyncSpy.mockRestore();
+  });
+
+  it('should create bulk export directory with permissions', () => {
+    const bulkExportDir = path.join(tmpDir, 'bulk-export');
+    fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    const lchownSyncSpy = vi
+      .spyOn(fs, 'lchownSync')
+      .mockImplementation(() => {});
+
+    setupDirectories(
+      path.join(tmpDir, 'data', 'uploads'),
+      path.join(tmpDir, 'public', 'uploads'),
+      bulkExportDir,
+    );
+
+    expect(fs.existsSync(bulkExportDir)).toBe(true);
+    const stat = fs.statSync(bulkExportDir);
+    expect(stat.mode & 0o777).toBe(0o700);
+
+    chownSyncSpy.mockRestore();
+    lchownSyncSpy.mockRestore();
+  });
+});

+ 265 - 0
apps/app/docker/docker-entrypoint.ts

@@ -0,0 +1,265 @@
+/**
+ * Docker entrypoint for GROWI (TypeScript)
+ *
+ * Runs directly with Node.js 24 native type stripping.
+ * Uses only erasable TypeScript syntax (no enums, no namespaces).
+ *
+ * Responsibilities:
+ * - Directory setup (as root): /data/uploads, symlinks, /tmp/page-bulk-export
+ * - Heap size detection: V8_MAX_HEAP_SIZE → cgroup auto-calc → V8 default
+ * - Privilege drop: process.setgid + process.setuid (root → node)
+ * - Migration execution: execFileSync (no shell)
+ * - App process spawn: spawn with signal forwarding
+ */
+
+/** biome-ignore-all lint/suspicious/noConsole: Allow printing to console */
+
+import { execFileSync, spawn } from 'node:child_process';
+import fs from 'node:fs';
+
+// -- Constants --
+
+const NODE_UID = 1000;
+const NODE_GID = 1000;
+const CGROUP_V2_PATH = '/sys/fs/cgroup/memory.max';
+const CGROUP_V1_PATH = '/sys/fs/cgroup/memory/memory.limit_in_bytes';
+const CGROUP_V1_UNLIMITED_THRESHOLD = 64 * 1024 * 1024 * 1024; // 64GB
+const HEAP_RATIO = 0.6;
+
+// -- Exported utility functions --
+
+/**
+ * Recursively chown a directory and all its contents.
+ */
+export function chownRecursive(
+  dirPath: string,
+  uid: number,
+  gid: number,
+): void {
+  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
+  for (const entry of entries) {
+    const fullPath = `${dirPath}/${entry.name}`;
+    if (entry.isDirectory()) {
+      chownRecursive(fullPath, uid, gid);
+    } else {
+      fs.chownSync(fullPath, uid, gid);
+    }
+  }
+  fs.chownSync(dirPath, uid, gid);
+}
+
+/**
+ * Read a cgroup memory limit file and return the numeric value in bytes.
+ * Returns undefined if the file cannot be read or the value is "max" / NaN.
+ */
+export function readCgroupLimit(filePath: string): number | undefined {
+  try {
+    const content = fs.readFileSync(filePath, 'utf-8').trim();
+    if (content === 'max') return undefined;
+    const value = parseInt(content, 10);
+    if (Number.isNaN(value)) return undefined;
+    return value;
+  } catch {
+    return undefined;
+  }
+}
+
+/**
+ * Detect heap size (MB) using 3-level fallback:
+ * 1. V8_MAX_HEAP_SIZE env var
+ * 2. cgroup v2/v1 auto-calculation (60% of limit)
+ * 3. undefined (V8 default)
+ */
+export function detectHeapSize(): number | undefined {
+  // Priority 1: V8_MAX_HEAP_SIZE env
+  const envValue = process.env.V8_MAX_HEAP_SIZE;
+  if (envValue != null && envValue !== '') {
+    const parsed = parseInt(envValue, 10);
+    if (Number.isNaN(parsed)) {
+      console.error(
+        `[entrypoint] V8_MAX_HEAP_SIZE="${envValue}" is not a valid number, ignoring`,
+      );
+      return undefined;
+    }
+    return parsed;
+  }
+
+  // Priority 2: cgroup v2
+  const cgroupV2 = readCgroupLimit(CGROUP_V2_PATH);
+  if (cgroupV2 != null) {
+    return Math.floor((cgroupV2 / 1024 / 1024) * HEAP_RATIO);
+  }
+
+  // Priority 3: cgroup v1 (treat > 64GB as unlimited)
+  const cgroupV1 = readCgroupLimit(CGROUP_V1_PATH);
+  if (cgroupV1 != null && cgroupV1 < CGROUP_V1_UNLIMITED_THRESHOLD) {
+    return Math.floor((cgroupV1 / 1024 / 1024) * HEAP_RATIO);
+  }
+
+  // Priority 4: V8 default
+  return undefined;
+}
+
+/**
+ * Build Node.js flags array based on heap size and environment variables.
+ */
+export function buildNodeFlags(heapSize: number | undefined): string[] {
+  const flags: string[] = ['--expose_gc'];
+
+  if (heapSize != null) {
+    flags.push(`--max-heap-size=${heapSize}`);
+  }
+
+  if (process.env.V8_OPTIMIZE_FOR_SIZE === 'true') {
+    flags.push('--optimize-for-size');
+  }
+
+  if (process.env.V8_LITE_MODE === 'true') {
+    flags.push('--lite-mode');
+  }
+
+  return flags;
+}
+
+/**
+ * Setup required directories (as root).
+ * - /data/uploads with symlink to ./public/uploads
+ * - /tmp/page-bulk-export with mode 700
+ */
+export function setupDirectories(
+  uploadsDir: string,
+  publicUploadsLink: string,
+  bulkExportDir: string,
+): void {
+  // /data/uploads
+  fs.mkdirSync(uploadsDir, { recursive: true });
+  if (!fs.existsSync(publicUploadsLink)) {
+    fs.symlinkSync(uploadsDir, publicUploadsLink);
+  }
+  chownRecursive(uploadsDir, NODE_UID, NODE_GID);
+  fs.lchownSync(publicUploadsLink, NODE_UID, NODE_GID);
+
+  // /tmp/page-bulk-export
+  fs.mkdirSync(bulkExportDir, { recursive: true });
+  chownRecursive(bulkExportDir, NODE_UID, NODE_GID);
+  fs.chmodSync(bulkExportDir, 0o700);
+}
+
+/**
+ * Drop privileges from root to node user.
+ * These APIs are POSIX-only and guaranteed to exist in the Docker container (Linux).
+ */
+export function dropPrivileges(): void {
+  if (process.setgid == null || process.setuid == null) {
+    throw new Error('Privilege drop APIs not available (non-POSIX platform)');
+  }
+  process.setgid(NODE_GID);
+  process.setuid(NODE_UID);
+}
+
+/**
+ * Log applied Node.js flags to stdout.
+ */
+function logFlags(heapSize: number | undefined, flags: string[]): void {
+  const source = (() => {
+    if (
+      process.env.V8_MAX_HEAP_SIZE != null &&
+      process.env.V8_MAX_HEAP_SIZE !== ''
+    ) {
+      return 'V8_MAX_HEAP_SIZE env';
+    }
+    if (heapSize != null) return 'cgroup auto-detection';
+    return 'V8 default (no heap limit)';
+  })();
+
+  console.log(`[entrypoint] Heap size source: ${source}`);
+  console.log(`[entrypoint] Node.js flags: ${flags.join(' ')}`);
+}
+
+/**
+ * Run database migration via execFileSync (no shell needed).
+ * Equivalent to: node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js
+ */
+function runMigration(): void {
+  console.log('[entrypoint] Running migration...');
+  execFileSync(
+    process.execPath,
+    [
+      '-r',
+      'dotenv-flow/config',
+      'node_modules/migrate-mongo/bin/migrate-mongo',
+      'up',
+      '-f',
+      'config/migrate-mongo-config.js',
+    ],
+    {
+      stdio: 'inherit',
+      env: { ...process.env, NODE_ENV: 'production' },
+    },
+  );
+  console.log('[entrypoint] Migration completed');
+}
+
+/**
+ * Spawn the application process and forward signals.
+ */
+function spawnApp(nodeFlags: string[]): void {
+  const child = spawn(
+    process.execPath,
+    [...nodeFlags, '-r', 'dotenv-flow/config', 'dist/server/app.js'],
+    {
+      stdio: 'inherit',
+      env: { ...process.env, NODE_ENV: 'production' },
+    },
+  );
+
+  // PID 1 signal forwarding
+  const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP'];
+  for (const sig of signals) {
+    process.on(sig, () => child.kill(sig));
+  }
+
+  child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => {
+    process.exit(code ?? (signal === 'SIGTERM' ? 0 : 1));
+  });
+}
+
+// -- Main entrypoint --
+
+function main(): void {
+  try {
+    // Step 1: Directory setup (as root)
+    setupDirectories(
+      '/data/uploads',
+      './public/uploads',
+      '/tmp/page-bulk-export',
+    );
+
+    // Step 2: Detect heap size and build flags
+    const heapSize = detectHeapSize();
+    const nodeFlags = buildNodeFlags(heapSize);
+    logFlags(heapSize, nodeFlags);
+
+    // Step 3: Drop privileges (root → node)
+    dropPrivileges();
+
+    // Step 4: Run migration
+    runMigration();
+
+    // Step 5: Start application
+    spawnApp(nodeFlags);
+  } catch (err) {
+    console.error('[entrypoint] Fatal error:', err);
+    process.exit(1);
+  }
+}
+
+// Run main only when executed directly (not when imported for testing)
+const isMainModule =
+  process.argv[1] != null &&
+  (process.argv[1].endsWith('docker-entrypoint.ts') ||
+    process.argv[1].endsWith('docker-entrypoint.js'));
+
+if (isMainModule) {
+  main();
+}

+ 0 - 5
apps/app/next-env.d.ts

@@ -1,5 +0,0 @@
-/// <reference types="next" />
-/// <reference types="next/image-types/global" />
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.

+ 0 - 173
apps/app/next.config.js

@@ -1,173 +0,0 @@
-/**
- * == Notes for production build==
- * The modules required from this file must be transpiled before running `next build`.
- *
- * See: https://github.com/vercel/next.js/discussions/35969#discussioncomment-2522954
- */
-
-const path = require('node:path');
-
-const { withSuperjson } = require('next-superjson');
-const {
-  PHASE_PRODUCTION_BUILD,
-  PHASE_PRODUCTION_SERVER,
-} = require('next/constants');
-
-const getTranspilePackages = () => {
-  const { listPrefixedPackages } = require('./src/utils/next.config.utils');
-
-  const packages = [
-    // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
-    'react-markdown',
-    'unified',
-    'markdown-table',
-    'bail',
-    'ccount',
-    'character-entities',
-    'character-entities-html4',
-    'character-entities-legacy',
-    'comma-separated-tokens',
-    'decode-named-character-reference',
-    'devlop',
-    'fault',
-    'escape-string-regexp',
-    'hastscript',
-    'html-void-elements',
-    'is-absolute-url',
-    'is-plain-obj',
-    'longest-streak',
-    'micromark',
-    'property-information',
-    'space-separated-tokens',
-    'stringify-entities',
-    'trim-lines',
-    'trough',
-    'web-namespaces',
-    'vfile',
-    'vfile-location',
-    'vfile-message',
-    'zwitch',
-    'emoticon',
-    'direction', // for hast-util-select
-    'bcp-47-match', // for hast-util-select
-    'parse-entities',
-    'character-reference-invalid',
-    'is-hexadecimal',
-    'is-alphabetical',
-    'is-alphanumerical',
-    'github-slugger',
-    'html-url-attributes',
-    'estree-util-is-identifier-name',
-    'superjson',
-    ...listPrefixedPackages([
-      'remark-',
-      'rehype-',
-      'hast-',
-      'mdast-',
-      'micromark-',
-      'unist-',
-    ]),
-  ];
-
-  // const eazyLogger = require('eazy-logger');
-  // const logger = eazyLogger.Logger({
-  //   prefix: '[{green:next.config.js}] ',
-  //   useLevelPrefixes: false,
-  // });
-  // logger.info('{bold:Listing scoped packages for transpiling:}');
-  // logger.unprefixed('info', `{grey:${JSON.stringify(packages, null, 2)}}`);
-
-  return packages;
-};
-
-const optimizePackageImports = [
-  '@growi/core',
-  '@growi/editor',
-  '@growi/pluginkit',
-  '@growi/presentation',
-  '@growi/preset-themes',
-  '@growi/remark-attachment-refs',
-  '@growi/remark-drawio',
-  '@growi/remark-growi-directive',
-  '@growi/remark-lsx',
-  '@growi/slack',
-  '@growi/ui',
-];
-
-module.exports = async (phase) => {
-  const { i18n, localePath } = require('./config/next-i18next.config');
-
-  /** @type {import('next').NextConfig} */
-  const nextConfig = {
-    reactStrictMode: true,
-    poweredByHeader: false,
-    pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
-    i18n,
-
-    // for build
-    typescript: {
-      tsconfigPath: 'tsconfig.build.client.json',
-    },
-    transpilePackages:
-      phase !== PHASE_PRODUCTION_SERVER ? getTranspilePackages() : undefined,
-    experimental: {
-      optimizePackageImports,
-    },
-
-    /** @param config {import('next').NextConfig} */
-    webpack(config, options) {
-      if (!options.isServer) {
-        // Avoid "Module not found: Can't resolve 'fs'"
-        // See: https://stackoverflow.com/a/68511591
-        config.resolve.fallback.fs = false;
-
-        // exclude packages from the output bundles
-        config.module.rules.push(
-          ...[
-            /dtrace-provider/,
-            /mongoose/,
-            /mathjax-full/, // required from marp
-          ].map((packageRegExp) => {
-            return {
-              test: packageRegExp,
-              use: 'null-loader',
-            };
-          }),
-        );
-      }
-
-      // extract sourcemap
-      if (options.dev) {
-        config.module.rules.push({
-          test: /.(c|m)?js$/,
-          exclude: [/node_modules/, path.resolve(__dirname)],
-          enforce: 'pre',
-          use: ['source-map-loader'],
-        });
-      }
-
-      // setup i18next-hmr
-      if (!options.isServer && options.dev) {
-        const { I18NextHMRPlugin } = require('i18next-hmr/webpack');
-        config.plugins.push(new I18NextHMRPlugin({ localesDir: localePath }));
-      }
-
-      return config;
-    },
-  };
-
-  // production server
-  // Skip withSuperjson() in production server phase because the pages directory
-  // doesn't exist in the production build and withSuperjson() tries to find it
-  if (phase === PHASE_PRODUCTION_SERVER) {
-    return nextConfig;
-  }
-
-  const withBundleAnalyzer = require('@next/bundle-analyzer')({
-    enabled:
-      phase === PHASE_PRODUCTION_BUILD &&
-      (process.env.ANALYZE === 'true' || process.env.ANALYZE === '1'),
-  });
-
-  return withBundleAnalyzer(withSuperjson()(nextConfig));
-};

+ 26 - 0
apps/app/next.config.prod.cjs

@@ -0,0 +1,26 @@
+/**
+ * Minimal Next.js config for production runtime.
+ *
+ * next.config.ts is the authoritative config used at build time (Turbopack rules,
+ * transpilePackages, sassOptions, etc.).  However, Next.js 16 tries to transpile
+ * .ts configs at server startup, which fails in production where TypeScript is not
+ * installed.  assemble-prod.sh therefore deletes next.config.ts and renames this
+ * file to next.config.js so the production server can load the runtime-critical
+ * settings (i18n routing, pageExtensions, …) without a TypeScript toolchain.
+ *
+ * Keep the runtime-relevant values in sync with next.config.ts.
+ */
+
+const nextI18nConfig = require('./config/next-i18next.config');
+
+const { i18n } = nextI18nConfig;
+
+/** @type {import('next').NextConfig} */
+module.exports = {
+  reactStrictMode: true,
+  poweredByHeader: false,
+  pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
+  i18n,
+
+  serverExternalPackages: ['handsontable'],
+};

+ 147 - 0
apps/app/next.config.ts

@@ -0,0 +1,147 @@
+/**
+ * == Notes for production build==
+ * The modules required from this file must be transpiled before running `next build`.
+ *
+ * See: https://github.com/vercel/next.js/discussions/35969#discussioncomment-2522954
+ */
+
+import type { NextConfig } from 'next';
+import path from 'node:path';
+
+import nextI18nConfig from './config/next-i18next.config';
+import { listPrefixedPackages } from './src/utils/next.config.utils';
+
+const { i18n } = nextI18nConfig;
+
+const getTranspilePackages = (): string[] => {
+  const packages = [
+    // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
+    'react-markdown',
+    'unified',
+    'markdown-table',
+    'bail',
+    'ccount',
+    'character-entities',
+    'character-entities-html4',
+    'character-entities-legacy',
+    'comma-separated-tokens',
+    'decode-named-character-reference',
+    'devlop',
+    'fault',
+    'escape-string-regexp',
+    'hastscript',
+    'html-void-elements',
+    'is-absolute-url',
+    'is-plain-obj',
+    'longest-streak',
+    'micromark',
+    'property-information',
+    'space-separated-tokens',
+    'stringify-entities',
+    'trim-lines',
+    'trough',
+    'web-namespaces',
+    'vfile',
+    'vfile-location',
+    'vfile-message',
+    'zwitch',
+    'emoticon',
+    'direction', // for hast-util-select
+    'bcp-47-match', // for hast-util-select
+    'parse-entities',
+    'character-reference-invalid',
+    'is-hexadecimal',
+    'is-alphabetical',
+    'is-alphanumerical',
+    'github-slugger',
+    'html-url-attributes',
+    'estree-util-is-identifier-name',
+    'superjson',
+    ...listPrefixedPackages([
+      'remark-',
+      'rehype-',
+      'hast-',
+      'mdast-',
+      'micromark-',
+      'unist-',
+    ]),
+  ];
+
+  return packages;
+};
+
+const optimizePackageImports: string[] = [
+  '@growi/core',
+  '@growi/editor',
+  '@growi/pluginkit',
+  '@growi/presentation',
+  '@growi/preset-themes',
+  '@growi/remark-attachment-refs',
+  '@growi/remark-drawio',
+  '@growi/remark-growi-directive',
+  '@growi/remark-lsx',
+  '@growi/slack',
+  '@growi/ui',
+];
+
+// This config is used at build time only (next build / next dev).
+// Production runtime uses next.config.prod.cjs (installed as next.config.js by assemble-prod.sh).
+const nextConfig: NextConfig = {
+  reactStrictMode: true,
+  poweredByHeader: false,
+  pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
+  i18n,
+
+  serverExternalPackages: [
+    'handsontable', // Legacy v6.2.2 requires @babel/polyfill which is unavailable; client-only via dynamic import
+  ],
+
+  // for build
+  typescript: {
+    tsconfigPath: 'tsconfig.build.client.json',
+  },
+  transpilePackages: getTranspilePackages(),
+  sassOptions: {
+    loadPaths: [path.resolve(__dirname, 'src')],
+  },
+  experimental: {
+    optimizePackageImports,
+  },
+
+  turbopack: {
+    rules: {
+      // Server-only: auto-wrap getServerSideProps with SuperJSON serialization
+      '*.page.ts': [
+        {
+          condition: { not: 'browser' },
+          loaders: [
+            path.resolve(__dirname, 'src/utils/superjson-ssr-loader.ts'),
+          ],
+          as: '*.ts',
+        },
+      ],
+      '*.page.tsx': [
+        {
+          condition: { not: 'browser' },
+          loaders: [
+            path.resolve(__dirname, 'src/utils/superjson-ssr-loader.ts'),
+          ],
+          as: '*.tsx',
+        },
+      ],
+    },
+    resolveAlias: {
+      // Exclude fs from client bundle
+      fs: { browser: './src/lib/empty-module.ts' },
+      // Exclude server-only packages from client bundle
+      'dtrace-provider': { browser: './src/lib/empty-module.ts' },
+      mongoose: { browser: './src/lib/empty-module.ts' },
+      'i18next-fs-backend': { browser: './src/lib/empty-module.ts' },
+      bunyan: { browser: './src/lib/empty-module.ts' },
+      'bunyan-format': { browser: './src/lib/empty-module.ts' },
+      'core-js': { browser: './src/lib/empty-module.ts' },
+    },
+  },
+};
+
+export default nextConfig;

+ 51 - 34
apps/app/package.json

@@ -1,8 +1,8 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.4.8-RC.0",
+  "version": "7.5.0-RC.0",
   "license": "MIT",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
     "build": "run-p build:*",
     "build": "run-p build:*",
@@ -10,15 +10,17 @@
     "build:client": "next build",
     "build:client": "next build",
     "build:server": "cross-env NODE_ENV=production tspc -p tsconfig.build.server.json",
     "build:server": "cross-env NODE_ENV=production tspc -p tsconfig.build.server.json",
     "postbuild:server": "shx echo \"Listing files under transpiled\" && shx ls transpiled && shx rm -rf dist && shx mv transpiled/src dist && shx rm -rf transpiled",
     "postbuild:server": "shx echo \"Listing files under transpiled\" && shx ls transpiled && shx rm -rf dist && shx mv transpiled/src dist && shx rm -rf transpiled",
-    "clean": "shx rm -rf dist transpiled",
+    "clean": "shx rm -rf dist transpiled .next next.config.js",
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "pnpm run server --ci",
     "server:ci": "pnpm run server --ci",
     "preserver": "cross-env NODE_ENV=production pnpm run migrate",
     "preserver": "cross-env NODE_ENV=production pnpm run migrate",
-    "pre:styles": "vite build -c vite.styles-prebuilt.config.ts",
+    "pre:styles-commons": "vite build -c vite.vendor-styles-commons.ts",
+    "pre:styles-components": "vite build --config vite.vendor-styles-components.ts",
     "migrate": "node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js",
     "migrate": "node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js",
     "//// for development": "",
     "//// for development": "",
     "dev": "cross-env NODE_ENV=development nodemon --exec pnpm run ts-node --inspect src/server/app.ts",
     "dev": "cross-env NODE_ENV=development nodemon --exec pnpm run ts-node --inspect src/server/app.ts",
-    "dev:pre:styles": "pnpm run pre:styles --mode dev",
+    "dev:pre:styles-commons": "pnpm run pre:styles-commons --mode dev",
+    "dev:pre:styles-components": "pnpm run pre:styles-components",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
     "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",
     "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",
@@ -32,6 +34,7 @@
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
     "lint": "run-p lint:**",
     "lint": "run-p lint:**",
+    "prelint:typecheck": "next typegen",
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
     "test": "vitest run",
     "test": "vitest run",
@@ -67,6 +70,14 @@
     "@azure/openai": "^2.0.0",
     "@azure/openai": "^2.0.0",
     "@azure/storage-blob": "^12.16.0",
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
+    "@codemirror/autocomplete": "^6.18.4",
+    "@codemirror/commands": "^6.8.0",
+    "@codemirror/lang-markdown": "^6.3.2",
+    "@codemirror/language": "^6.12.1",
+    "@codemirror/language-data": "^6.5.1",
+    "@codemirror/merge": "^6.8.0",
+    "@codemirror/state": "^6.5.2",
+    "@codemirror/view": "^6.39.14",
     "@cspell/dynamic-import": "^8.15.4",
     "@cspell/dynamic-import": "^8.15.4",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.4",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.4",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.18.2",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.18.2",
@@ -74,6 +85,7 @@
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "workspace:^",
     "@growi/core": "workspace:^",
+    "@growi/emoji-mart-data": "workspace:^",
     "@growi/pdf-converter-client": "workspace:^",
     "@growi/pdf-converter-client": "workspace:^",
     "@growi/pluginkit": "workspace:^",
     "@growi/pluginkit": "workspace:^",
     "@growi/presentation": "workspace:^",
     "@growi/presentation": "workspace:^",
@@ -84,7 +96,13 @@
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/slack": "workspace:^",
     "@growi/slack": "workspace:^",
+    "@handsontable/react": "=2.1.0",
+    "@headless-tree/core": "^1.5.3",
+    "@headless-tree/react": "^1.5.3",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@keycloak/keycloak-admin-client": "^18.0.0",
+    "@lezer/highlight": "^1.2.3",
+    "@marp-team/marp-core": "^3.9.1",
+    "@marp-team/marpit": "^2.6.1",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/auto-instrumentations-node": "^0.60.1",
     "@opentelemetry/auto-instrumentations-node": "^0.60.1",
     "@opentelemetry/exporter-metrics-otlp-grpc": "^0.202.0",
     "@opentelemetry/exporter-metrics-otlp-grpc": "^0.202.0",
@@ -94,8 +112,12 @@
     "@opentelemetry/sdk-node": "^0.202.0",
     "@opentelemetry/sdk-node": "^0.202.0",
     "@opentelemetry/sdk-trace-node": "^2.0.1",
     "@opentelemetry/sdk-trace-node": "^2.0.1",
     "@opentelemetry/semantic-conventions": "^1.34.0",
     "@opentelemetry/semantic-conventions": "^1.34.0",
+    "@replit/codemirror-emacs": "^6.1.0",
+    "@replit/codemirror-vim": "^6.2.1",
+    "@replit/codemirror-vscode-keymap": "^6.0.2",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
+    "@tanstack/react-virtual": "^3.13.12",
     "@types/async": "^3.2.24",
     "@types/async": "^3.2.24",
     "@types/multer": "^1.4.12",
     "@types/multer": "^1.4.12",
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",
@@ -106,9 +128,12 @@
     "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",
+    "bootstrap": "=5.3.2",
     "browser-bunyan": "^1.8.0",
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
     "bson-objectid": "^2.0.4",
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",
+    "cm6-theme-basic-light": "^0.2.0",
+    "codemirror": "^6.0.1",
     "compression": "^1.7.4",
     "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",
     "connect-mongo": "^4.6.0",
@@ -121,8 +146,10 @@
     "dayjs": "^1.11.7",
     "dayjs": "^1.11.7",
     "detect-indent": "^7.0.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
     "diff": "^5.0.0",
+    "diff2html": "^3.4.47",
     "diff_match_patch": "^0.1.1",
     "diff_match_patch": "^0.1.1",
     "dotenv-flow": "^3.2.0",
     "dotenv-flow": "^3.2.0",
+    "downshift": "^8.2.3",
     "ejs": "^3.1.10",
     "ejs": "^3.1.10",
     "escape-string-regexp": "^4.0.0",
     "escape-string-regexp": "^4.0.0",
     "expose-gc": "^1.0.0",
     "expose-gc": "^1.0.0",
@@ -132,6 +159,7 @@
     "express-session": "^1.16.1",
     "express-session": "^1.16.1",
     "express-validator": "^6.14.0",
     "express-validator": "^6.14.0",
     "extensible-custom-error": "^0.0.7",
     "extensible-custom-error": "^0.0.7",
+    "fastest-levenshtein": "^1.0.16",
     "form-data": "^4.0.4",
     "form-data": "^4.0.4",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
     "hast-util-sanitize": "^5.0.1",
     "hast-util-sanitize": "^5.0.1",
@@ -140,6 +168,8 @@
     "helmet": "^4.6.0",
     "helmet": "^4.6.0",
     "http-errors": "^2.0.0",
     "http-errors": "^2.0.0",
     "i18next": "^23.16.5",
     "i18next": "^23.16.5",
+    "i18next-http-backend": "^2.6.2",
+    "i18next-localstorage-backend": "^4.2.0",
     "i18next-resources-to-backend": "^1.2.1",
     "i18next-resources-to-backend": "^1.2.1",
     "is-absolute-url": "^4.0.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
@@ -170,10 +200,9 @@
     "multer": "~1.4.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
     "mustache": "^4.2.0",
-    "next": "^14.2.35",
+    "next": "^16.0.0",
     "next-dynamic-loading-props": "^0.1.1",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-i18next": "^15.3.1",
-    "next-superjson": "^1.0.7",
     "next-themes": "^0.2.1",
     "next-themes": "^0.2.1",
     "nocache": "^4.0.0",
     "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
     "node-cron": "^3.0.2",
@@ -182,6 +211,7 @@
     "openai": "^4.96.2",
     "openai": "^4.96.2",
     "openid-client": "^5.4.0",
     "openid-client": "^5.4.0",
     "p-retry": "^4.0.0",
     "p-retry": "^4.0.0",
+    "pako": "^2.1.0",
     "passport": "^0.6.0",
     "passport": "^0.6.0",
     "passport-github": "^1.1.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-google-oauth20": "^2.0.0",
@@ -189,23 +219,31 @@
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "passport-saml": "^3.2.0",
     "pathe": "^2.0.3",
     "pathe": "^2.0.3",
+    "pretty-bytes": "^6.1.1",
     "prop-types": "^15.8.1",
     "prop-types": "^15.8.1",
     "qs": "^6.14.2",
     "qs": "^6.14.2",
     "rate-limiter-flexible": "^2.3.7",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",
     "react-bootstrap-typeahead": "^6.3.2",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
+    "react-copy-to-clipboard": "^5.0.1",
     "react-datepicker": "^4.7.0",
     "react-datepicker": "^4.7.0",
     "react-disable": "^0.1.1",
     "react-disable": "^0.1.1",
+    "react-dnd": "^14.0.5",
+    "react-dnd-html5-backend": "^14.1.0",
     "react-dom": "^18.2.0",
     "react-dom": "^18.2.0",
+    "react-dropzone": "^14.2.3",
     "react-error-boundary": "^3.1.4",
     "react-error-boundary": "^3.1.4",
+    "react-hook-form": "^7.45.4",
     "react-i18next": "^15.1.1",
     "react-i18next": "^15.1.1",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
+    "react-input-autosize": "^3.0.0",
     "react-markdown": "^9.0.1",
     "react-markdown": "^9.0.1",
     "react-multiline-clamp": "^2.0.0",
     "react-multiline-clamp": "^2.0.0",
     "react-scroll": "^1.8.7",
     "react-scroll": "^1.8.7",
     "react-stickynode": "^4.1.1",
     "react-stickynode": "^4.1.1",
-    "react-syntax-highlighter": "^15.5.0",
+    "react-syntax-highlighter": "^16.1.0",
+    "react-toastify": "^9.1.3",
     "react-use-ripple": "^1.5.2",
     "react-use-ripple": "^1.5.2",
     "reactstrap": "^9.2.2",
     "reactstrap": "^9.2.2",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",
@@ -226,7 +264,9 @@
     "remark-parse": "^11.0.0",
     "remark-parse": "^11.0.0",
     "remark-rehype": "^11.1.1",
     "remark-rehype": "^11.1.1",
     "remark-stringify": "^11.0.0",
     "remark-stringify": "^11.0.0",
+    "reveal.js": "^4.4.8",
     "sanitize-filename": "^1.6.3",
     "sanitize-filename": "^1.6.3",
+    "simplebar-react": "^2.3.6",
     "socket.io": "^4.7.5",
     "socket.io": "^4.7.5",
     "string-width": "=4.2.2",
     "string-width": "=4.2.2",
     "superjson": "^2.2.2",
     "superjson": "^2.2.2",
@@ -248,6 +288,7 @@
     "validator": "^13.15.22",
     "validator": "^13.15.22",
     "ws": "^8.17.1",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
     "xss": "^1.0.15",
+    "y-codemirror.next": "^0.3.5",
     "y-mongodb-provider": "^0.2.0",
     "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
     "y-socket.io": "^1.1.3",
     "yjs": "^13.6.18",
     "yjs": "^13.6.18",
@@ -261,18 +302,11 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@apidevtools/swagger-parser": "^10.1.1",
     "@apidevtools/swagger-parser": "^10.1.1",
-    "@codemirror/state": "^6.5.2",
-    "@emoji-mart/data": "^1.2.1",
     "@growi/core-styles": "workspace:^",
     "@growi/core-styles": "workspace:^",
     "@growi/custom-icons": "workspace:^",
     "@growi/custom-icons": "workspace:^",
     "@growi/editor": "workspace:^",
     "@growi/editor": "workspace:^",
     "@growi/ui": "workspace:^",
     "@growi/ui": "workspace:^",
-    "@handsontable/react": "=2.1.0",
-    "@headless-tree/core": "^1.5.3",
-    "@headless-tree/react": "^1.5.3",
-    "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
     "@popperjs/core": "^2.11.8",
-    "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/user-event": "^14.5.2",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
     "@types/archiver": "^6.0.2",
@@ -298,20 +332,13 @@
     "@types/uuid": "^10.0.0",
     "@types/uuid": "^10.0.0",
     "@types/ws": "^8.18.1",
     "@types/ws": "^8.18.1",
     "babel-loader": "^8.2.5",
     "babel-loader": "^8.2.5",
-    "bootstrap": "=5.3.2",
     "commander": "^14.0.0",
     "commander": "^14.0.0",
     "connect-browser-sync": "^2.1.0",
     "connect-browser-sync": "^2.1.0",
-    "diff2html": "^3.4.47",
-    "downshift": "^8.2.3",
     "eazy-logger": "^3.1.0",
     "eazy-logger": "^3.1.0",
-    "fastest-levenshtein": "^1.0.16",
     "fslightbox-react": "^1.7.6",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
     "handsontable": "=6.2.2",
     "happy-dom": "^15.7.4",
     "happy-dom": "^15.7.4",
     "i18next-chained-backend": "^4.6.2",
     "i18next-chained-backend": "^4.6.2",
-    "i18next-hmr": "^3.1.3",
-    "i18next-http-backend": "^2.6.2",
-    "i18next-localstorage-backend": "^4.2.0",
     "jotai-devtools": "^0.11.0",
     "jotai-devtools": "^0.11.0",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "material-icons": "^1.11.3",
@@ -320,25 +347,15 @@
     "mongodb-connection-string-url": "^7.0.0",
     "mongodb-connection-string-url": "^7.0.0",
     "mongodb-memory-server-core": "^9.1.1",
     "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",
-    "null-loader": "^4.0.1",
     "openapi-typescript": "^7.8.0",
     "openapi-typescript": "^7.8.0",
-    "pretty-bytes": "^6.1.1",
-    "react-copy-to-clipboard": "^5.0.1",
-    "react-dnd": "^14.0.5",
-    "react-dnd-html5-backend": "^14.1.0",
-    "react-dropzone": "^14.2.3",
-    "react-hook-form": "^7.45.4",
-    "react-hotkeys": "^2.0.0",
-    "react-input-autosize": "^3.0.0",
-    "react-toastify": "^9.1.3",
     "rehype-rewrite": "^4.0.2",
     "rehype-rewrite": "^4.0.2",
     "remark-github-admonitions-to-directives": "^2.0.0",
     "remark-github-admonitions-to-directives": "^2.0.0",
     "sass": "^1.53.0",
     "sass": "^1.53.0",
-    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "socket.io-client": "^4.7.5",
-    "source-map-loader": "^4.0.1",
     "supertest": "^7.1.4",
     "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
     "swagger2openapi": "^7.0.8",
+    "tinykeys": "^3.0.0",
+    "typescript": "~5.0.4",
     "unist-util-is": "^6.0.0",
     "unist-util-is": "^6.0.0",
     "unist-util-visit-parents": "^6.0.0"
     "unist-util-visit-parents": "^6.0.0"
   }
   }

+ 1 - 1
apps/app/playwright.config.ts

@@ -52,7 +52,7 @@ export default defineConfig({
   reporter: process.env.CI ? [['github'], ['blob']] : 'list',
   reporter: process.env.CI ? [['github'], ['blob']] : 'list',
 
 
   webServer: {
   webServer: {
-    command: 'pnpm run server',
+    command: process.env.GROWI_WEBSERVER_COMMAND ?? 'pnpm run server',
     url: 'http://localhost:3000',
     url: 'http://localhost:3000',
     reuseExistingServer: !process.env.CI,
     reuseExistingServer: !process.env.CI,
     stdout: 'ignore',
     stdout: 'ignore',

+ 5 - 3
apps/app/src/client/components/Admin/AuditLog/DateRangePicker.tsx

@@ -1,8 +1,10 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import React, { forwardRef, useCallback } from 'react';
-import { addDays, format } from 'date-fns';
+import { forwardRef, useCallback } from 'react';
+import { addDays } from 'date-fns/addDays';
+import { format } from 'date-fns/format';
 import DatePicker from 'react-datepicker';
 import DatePicker from 'react-datepicker';
-import 'react-datepicker/dist/react-datepicker.css';
+
+import './DateRangePicker.vendor-styles.prebuilt';
 
 
 type CustomInputProps = {
 type CustomInputProps = {
   value?: string;
   value?: string;

+ 6 - 0
apps/app/src/client/components/Admin/AuditLog/DateRangePicker.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from 'react-datepicker/dist/react-datepicker.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 3 - 3
apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx

@@ -1,8 +1,8 @@
-import React, { type JSX, useCallback, useEffect } from 'react';
+import { type JSX, useCallback, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
-import { PrismAsyncLight } from 'react-syntax-highlighter';
-import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+import PrismAsyncLight from 'react-syntax-highlighter/dist/esm/prism-async-light';
+import oneDark from 'react-syntax-highlighter/dist/esm/styles/prism/one-dark';
 import { Card, CardBody } from 'reactstrap';
 import { Card, CardBody } from 'reactstrap';
 
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';

+ 2 - 2
apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -1,8 +1,8 @@
 import React, { type JSX, useCallback, useEffect } from 'react';
 import React, { type JSX, useCallback, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
-import { PrismAsyncLight } from 'react-syntax-highlighter';
-import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+import PrismAsyncLight from 'react-syntax-highlighter/dist/esm/prism-async-light';
+import oneDark from 'react-syntax-highlighter/dist/esm/styles/prism/one-dark';
 import { Card, CardBody } from 'reactstrap';
 import { Card, CardBody } from 'reactstrap';
 
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';

+ 1 - 1
apps/app/src/client/components/Admin/Customize/ThemeColorBox.module.scss

@@ -1,6 +1,6 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 
 // layout
 // layout
-.theme-option-container :global {
+.theme-option-container {
   min-width: 100px;
   min-width: 100px;
 }
 }

+ 3 - 5
apps/app/src/client/components/Admin/UserGroup/UserGroupTable.module.scss

@@ -4,15 +4,13 @@
 
 
 // switch visibility of the edit icon
 // switch visibility of the edit icon
 .user-group-edit-link {
 .user-group-edit-link {
-  :global {
-    .grw-edit-icon {
+    :global(.grw-edit-icon) {
       visibility: hidden;
       visibility: hidden;
     }
     }
-  }
 
 
-  &:global {
+  & {
     &:hover {
     &:hover {
-      .grw-edit-icon {
+      :global(.grw-edit-icon) {
         visibility: visible;
         visibility: visible;
       }
       }
     }
     }

+ 9 - 9
apps/app/src/client/components/Admin/UserManagement.module.scss

@@ -1,18 +1,18 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 
 // styles for admin user search
 // styles for admin user search
-.search-typeahead :global {
+.search-typeahead {
   position: relative;
   position: relative;
   width: 100%;
   width: 100%;
 
 
   // corner radius
   // corner radius
   border-top-right-radius: bs.$border-radius;
   border-top-right-radius: bs.$border-radius;
   border-bottom-right-radius: bs.$border-radius;
   border-bottom-right-radius: bs.$border-radius;
-  .rbt-input-main,
-  input {
+  :global(.rbt-input-main),
+  :global(input) {
     padding-right: 36px;
     padding-right: 36px;
   }
   }
-  .search-clear {
+  :global(.search-clear) {
     position: absolute;
     position: absolute;
     top: 50%;
     top: 50%;
     right: 6px;
     right: 6px;
@@ -25,22 +25,22 @@
     transform: translateY(-50%);
     transform: translateY(-50%);
   }
   }
 
 
-  .rbt-menu {
+  :global(.rbt-menu) {
     max-height: none !important;
     max-height: none !important;
     margin-top: 3px;
     margin-top: 3px;
 
 
-    li a span {
-      .page-path {
+    :global(li a span) {
+      :global(.page-path) {
         display: inline;
         display: inline;
         padding: 0 4px;
         padding: 0 4px;
         color: inherit;
         color: inherit;
       }
       }
 
 
-      .page-list-meta {
+      :global(.page-list-meta) {
         font-size: 0.9em;
         font-size: 0.9em;
         color: bs.$gray-400;
         color: bs.$gray-400;
 
 
-        > span {
+        > :global(span) {
           margin-right: 0.3rem;
           margin-right: 0.3rem;
         }
         }
       }
       }

+ 3 - 3
apps/app/src/client/components/Admin/Users/ExternalAccountTable.module.scss

@@ -1,8 +1,8 @@
-.ea-table :global {
-  thead th {
+.ea-table {
+  :global(thead th) {
     vertical-align: top;
     vertical-align: top;
   }
   }
-  td {
+  :global(td) {
     vertical-align: middle;
     vertical-align: middle;
   }
   }
 }
 }

+ 1 - 1
apps/app/src/client/components/Admin/Users/UserMenu.module.scss

@@ -1,4 +1,4 @@
-.grw-usermenu-notification-icon :global {
+.grw-usermenu-notification-icon {
   position: absolute;
   position: absolute;
   top: -6px;
   top: -6px;
   left: 3px;
   left: 3px;

+ 4 - 4
apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss

@@ -3,19 +3,19 @@
 $author-font-size: 12px;
 $author-font-size: 12px;
 $date-font-size: 12px;
 $date-font-size: 12px;
 
 
-.grw-author-info :global {
+.grw-author-info {
   font-size: $author-font-size;
   font-size: $author-font-size;
 
 
-  .text-date {
+  :global(.text-date) {
     font-size: $date-font-size;
     font-size: $date-font-size;
   }
   }
 
 
-  .user-picture {
+  :global(.user-picture) {
     width: 22px;
     width: 22px;
     height: 22px;
     height: 22px;
     border: 1px solid bs.$gray-300;
     border: 1px solid bs.$gray-300;
 
 
-    &.user-picture-xs {
+    &:global(.user-picture-xs) {
       width: 14px;
       width: 14px;
       height: 14px;
       height: 14px;
     }
     }

+ 3 - 3
apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.module.scss

@@ -1,10 +1,10 @@
-.grw-bookmark-folder-menu  :global {
+.grw-bookmark-folder-menu {
   max-width: 65%;
   max-width: 65%;
 
 
-  .grw-bookmark-folder-menu-item-folder-first {
+  :global(.grw-bookmark-folder-menu-item-folder-first) {
     padding-left: 40px;
     padding-left: 40px;
   }
   }
-  .grw-bookmark-folder-menu-item-folder-second {
+  :global(.grw-bookmark-folder-menu-item-folder-second) {
     padding-left: 60px;
     padding-left: 60px;
   }
   }
 }
 }

+ 20 - 20
apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -1,54 +1,54 @@
 $grw-foldertree-item-padding-left: 15px;
 $grw-foldertree-item-padding-left: 15px;
 $grw-bookmark-item-padding-left: 35px;
 $grw-bookmark-item-padding-left: 35px;
 
 
-.grw-folder-tree-container :global {
-  .grw-foldertree-item-container, .grw-drop-item-area {
-    & .grw-accept-drop-item {
+.grw-folder-tree-container {
+  :global(.grw-foldertree-item-container), :global(.grw-drop-item-area) {
+    & :global(.grw-accept-drop-item) {
       border-style: dashed !important;
       border-style: dashed !important;
       border-width: 0.15rem !important;
       border-width: 0.15rem !important;
     }
     }
   }
   }
 
 
-  .grw-drop-item-area {
+  :global(.grw-drop-item-area) {
     padding: 1rem;
     padding: 1rem;
-    & .grw-accept-drop-item {
+    & :global(.grw-accept-drop-item) {
       padding: 0.7rem;
       padding: 0.7rem;
     }
     }
   }
   }
-  .grw-drag-drop-container > .grw-drop-item-area {
+  :global(.grw-drag-drop-container > .grw-drop-item-area) {
     margin: 1rem;
     margin: 1rem;
     border-style: dashed !important;
     border-style: dashed !important;
     border-width: 0.15rem !important;
     border-width: 0.15rem !important;
   }
   }
 }
 }
 
 
-.grw-foldertree :global {
+.grw-foldertree {
 
 
-  .list-group-item {
-    .grw-visible-on-hover {
+  :global(.list-group-item) {
+    :global(.grw-visible-on-hover) {
       display: none;
       display: none;
     }
     }
 
 
     &:hover {
     &:hover {
-      .grw-visible-on-hover {
+      :global(.grw-visible-on-hover) {
         display: block;
         display: block;
       }
       }
-      .page-list-meta {
+      :global(.page-list-meta) {
         display: none;
         display: none;
       }
       }
     }
     }
 
 
-    .grw-foldertree-triangle-btn {
+    :global(.grw-foldertree-triangle-btn) {
       border: 0;
       border: 0;
       transition: all 0.2s ease-out;
       transition: all 0.2s ease-out;
       transform: rotate(0deg);
       transform: rotate(0deg);
 
 
-      &.grw-foldertree-open {
+      &:global(.grw-foldertree-open) {
         transform: rotate(90deg);
         transform: rotate(90deg);
       }
       }
     }
     }
 
 
-    .grw-foldertree-title-anchor {
+    :global(.grw-foldertree-title-anchor) {
       width: 100%;
       width: 100%;
       overflow: hidden;
       overflow: hidden;
       font-size: 14px;
       font-size: 14px;
@@ -56,27 +56,27 @@ $grw-bookmark-item-padding-left: 35px;
     }
     }
   }
   }
 
 
-  .grw-foldertree-item-container {
-    .grw-triangle-container {
+  :global(.grw-foldertree-item-container) {
+    :global(.grw-triangle-container) {
       height:30px;
       height:30px;
     }
     }
 
 
-    .grw-bookmark-item-list{
+    :global(.grw-bookmark-item-list){
       min-width: 30px;
       min-width: 30px;
       height: 50px;
       height: 50px;
 
 
-      .user-picture {
+      :global(.user-picture) {
         width: 16px;
         width: 16px;
         height: 16px;
         height: 16px;
         vertical-align: text-bottom;
         vertical-align: text-bottom;
 
 
-        &.user-picture-md {
+        &:global(.user-picture-md) {
           width: 20px;
           width: 20px;
           height: 20px;
           height: 20px;
         }
         }
       }
       }
 
 
-      .grw-foldertree-control{
+      :global(.grw-foldertree-control){
         margin-left: auto;
         margin-left: auto;
       }
       }
     }
     }

+ 6 - 6
apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.module.scss

@@ -1,7 +1,7 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/ui/scss/atoms/btn-muted';
 @use '@growi/ui/scss/atoms/btn-muted';
 
 
-.copy-clipboard-dropdown-menu :global {
+.copy-clipboard-dropdown-menu {
     min-width: 310px;
     min-width: 310px;
     max-width: 375px;
     max-width: 375px;
 
 
@@ -9,25 +9,25 @@
       max-width: 600px;
       max-width: 600px;
     }
     }
 
 
-    .dropdown-header {
+    :global(.dropdown-header) {
       margin-bottom: 0.5em;
       margin-bottom: 0.5em;
       font-size: 1.1em;
       font-size: 1.1em;
     }
     }
 
 
     // unset active styles
     // unset active styles
-    .dropdown-item:active {
+    :global(.dropdown-item:active) {
       color: unset;
       color: unset;
       background-color: unset;
       background-color: unset;
     }
     }
 
 
-    .card {
+    :global(.card) {
       font-size: 0.7em;
       font-size: 0.7em;
       word-break: break-all;
       word-break: break-all;
     }
     }
   }
   }
 
 
-.grw-copy-dropdown :global {
-  .btn.btn-copy {
+.grw-copy-dropdown {
+  :global(.btn.btn-copy) {
     @include btn-muted.colorize(bs.$gray-500);
     @include btn-muted.colorize(bs.$gray-500);
   }
   }
 }
 }

+ 3 - 3
apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.module.scss

@@ -1,9 +1,9 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/variables' as var;
+@use 'styles/variables' as var;
 
 
 
 
-.grw-drawer-toggler :global {
-  .btn {
+.grw-drawer-toggler {
+  :global(.btn) {
     --bs-btn-color: rgba(var(--bs-tertiary-color-rgb), 0.5);
     --bs-btn-color: rgba(var(--bs-tertiary-color-rgb), 0.5);
     --bs-btn-bg: transparent;
     --bs-btn-bg: transparent;
     --bs-btn-hover-color: rgba(var(--bs-tertiary-color-rgb), 0.7);
     --bs-btn-hover-color: rgba(var(--bs-tertiary-color-rgb), 0.7);

+ 3 - 2
apps/app/src/client/components/Common/ImageCropModal.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import React, { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 import canvasToBlob from 'async-canvas-to-blob';
 import canvasToBlob from 'async-canvas-to-blob';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import ReactCrop from 'react-image-crop';
 import ReactCrop from 'react-image-crop';
@@ -7,7 +7,8 @@ import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
-import 'react-image-crop/dist/ReactCrop.css';
+
+import './ImageCropModal.vendor-styles.prebuilt';
 
 
 const logger = loggerFactory('growi:ImageCropModal');
 const logger = loggerFactory('growi:ImageCropModal');
 
 

+ 6 - 0
apps/app/src/client/components/Common/ImageCropModal.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from 'react-image-crop/dist/ReactCrop.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 3 - 3
apps/app/src/client/components/CompleteUserRegistrationForm.module.scss

@@ -1,8 +1,8 @@
-@use '~/styles/atoms/placeholders/buttons';
+@use 'styles/atoms/placeholders/buttons';
 
 
 :root {
 :root {
-  .complete-user-registration-form :global {
-    .btn-register {
+  .complete-user-registration-form {
+    :global(.btn-register) {
       @extend %btn-nologin;
       @extend %btn-nologin;
       @extend %btn-register;
       @extend %btn-register;
     }
     }

+ 4 - 4
apps/app/src/client/components/CustomNavigation/CustomNav.module.scss

@@ -1,15 +1,15 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 
-.grw-custom-nav-tab :global {
-  .nav-title {
+.grw-custom-nav-tab {
+  :global(.nav-title) {
     flex-wrap: nowrap;
     flex-wrap: nowrap;
   }
   }
 
 
-  .nav-link {
+  :global(.nav-link) {
     padding: 1rem 1.5rem;
     padding: 1rem 1.5rem;
   }
   }
 
 
-  .grw-nav-slide-hr {
+  :global(.grw-nav-slide-hr) {
     border-top: 0;
     border-top: 0;
     border-bottom: 3px solid;
     border-bottom: 3px solid;
     transition: 0.3s ease-in-out;
     transition: 0.3s ease-in-out;

+ 7 - 7
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.module.scss

@@ -1,22 +1,22 @@
-.grw-descendants-page-list-modal :global {
-  .modal-header {
-    button.btn-close {
+.grw-descendants-page-list-modal {
+  :global(.modal-header) {
+    :global(button.btn-close) {
       margin: auto 0 auto auto;
       margin: auto 0 auto auto;
     }
     }
   }
   }
 
 
-  .modal-body {
+  :global(.modal-body) {
     padding: 25px 30px;
     padding: 25px 30px;
   }
   }
 
 
-  .grw-tab-content-style-md-down {
+  :global(.grw-tab-content-style-md-down) {
     padding-top: 25px;
     padding-top: 25px;
   }
   }
 
 
-  .grw-modal-body-style {
+  :global(.grw-modal-body-style) {
     max-height: calc(100vh - 100px);
     max-height: calc(100vh - 100px);
   }
   }
-  ul.pagination {
+  :global(ul.pagination) {
     margin-bottom: 0;
     margin-bottom: 0;
   }
   }
 }
 }

+ 3 - 2
apps/app/src/client/components/FormattedDistanceDate.jsx

@@ -1,5 +1,6 @@
-import React from 'react';
-import { differenceInSeconds, format, formatDistanceStrict } from 'date-fns';
+import { differenceInSeconds } from 'date-fns/differenceInSeconds';
+import { format } from 'date-fns/format';
+import { formatDistanceStrict } from 'date-fns/formatDistanceStrict';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 

+ 6 - 0
apps/app/src/client/components/GrowiEditor.vendor-styles.ts

@@ -0,0 +1,6 @@
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from '@growi/editor/dist/style.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);

+ 0 - 77
apps/app/src/client/components/Hotkeys/HotkeysDetector.jsx

@@ -1,77 +0,0 @@
-import React, { useCallback, useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { GlobalHotKeys } from 'react-hotkeys';
-
-import HotkeyStroke from '~/client/models/HotkeyStroke';
-
-const HotkeysDetector = (props) => {
-  const { keySet, strokeSet, onDetected } = props;
-
-  // memorize HotkeyStroke instances
-  const hotkeyStrokes = useMemo(() => {
-    const strokes = Array.from(strokeSet);
-    return strokes.map((stroke) => new HotkeyStroke(stroke));
-  }, [strokeSet]);
-
-  /**
-   * return key expression string includes modifier
-   */
-  const getKeyExpression = useCallback((event) => {
-    let eventKey = event.key;
-
-    if (event.ctrlKey) {
-      eventKey += '+ctrl';
-    }
-    if (event.metaKey) {
-      eventKey += '+meta';
-    }
-    if (event.altKey) {
-      eventKey += '+alt';
-    }
-    if (event.shiftKey) {
-      eventKey += '+shift';
-    }
-
-    return eventKey;
-  }, []);
-
-  /**
-   * evaluate the key user pressed and trigger onDetected
-   */
-  const checkHandler = useCallback(
-    (event) => {
-      const eventKey = getKeyExpression(event);
-
-      hotkeyStrokes.forEach((hotkeyStroke) => {
-        // if any stroke is completed
-        if (hotkeyStroke.evaluate(eventKey)) {
-          // cancel the key event
-          event.preventDefault();
-          // invoke detected handler
-          onDetected(hotkeyStroke.stroke);
-        }
-      });
-    },
-    [hotkeyStrokes, getKeyExpression, onDetected],
-  );
-
-  // memorize keyMap for GlobalHotKeys
-  const keyMap = useMemo(() => {
-    return { check: Array.from(keySet) };
-  }, [keySet]);
-
-  // memorize handlers for GlobalHotKeys
-  const handlers = useMemo(() => {
-    return { check: checkHandler };
-  }, [checkHandler]);
-
-  return <GlobalHotKeys keyMap={keyMap} handlers={handlers} />;
-};
-
-HotkeysDetector.propTypes = {
-  onDetected: PropTypes.func.isRequired,
-  keySet: PropTypes.instanceOf(Set).isRequired,
-  strokeSet: PropTypes.instanceOf(Set).isRequired,
-};
-
-export default HotkeysDetector;

+ 0 - 81
apps/app/src/client/components/Hotkeys/HotkeysManager.jsx

@@ -1,81 +0,0 @@
-import React, { useState } from 'react';
-
-import HotkeysDetector from './HotkeysDetector';
-import CreatePage from './Subscribers/CreatePage';
-import EditPage from './Subscribers/EditPage';
-import FocusToGlobalSearch from './Subscribers/FocusToGlobalSearch';
-import ShowShortcutsModal from './Subscribers/ShowShortcutsModal';
-import ShowStaffCredit from './Subscribers/ShowStaffCredit';
-import SwitchToMirrorMode from './Subscribers/SwitchToMirrorMode';
-
-// define supported components list
-const SUPPORTED_COMPONENTS = [
-  ShowStaffCredit,
-  SwitchToMirrorMode,
-  ShowShortcutsModal,
-  CreatePage,
-  EditPage,
-  FocusToGlobalSearch,
-];
-
-const KEY_SET = new Set();
-const STROKE_SET = new Set();
-const STROKE_TO_COMPONENT_MAP = {};
-
-SUPPORTED_COMPONENTS.forEach((comp) => {
-  const strokes = comp.getHotkeyStrokes();
-
-  strokes.forEach((stroke) => {
-    // register key
-    stroke.forEach((key) => {
-      KEY_SET.add(key);
-    });
-    // register stroke
-    STROKE_SET.add(stroke);
-    // register component
-    const componentList = STROKE_TO_COMPONENT_MAP[stroke] || [];
-    componentList.push(comp);
-    STROKE_TO_COMPONENT_MAP[stroke.toString()] = componentList;
-  });
-});
-
-const HotkeysManager = (props) => {
-  const [view, setView] = useState([]);
-
-  /**
-   * delete the instance in state.view
-   */
-  const deleteRender = (instance) => {
-    const index = view.lastIndexOf(instance);
-
-    const newView = view.slice(); // shallow copy
-    newView.splice(index, 1);
-    setView(newView);
-  };
-
-  /**
-   * activates when one of the hotkey strokes gets determined from HotkeysDetector
-   */
-  const onDetected = (strokeDetermined) => {
-    const key = (Math.random() * 1000).toString();
-    const components = STROKE_TO_COMPONENT_MAP[strokeDetermined.toString()];
-
-    const newViews = components.map((Component) => (
-      <Component key={key} onDeleteRender={deleteRender} />
-    ));
-    setView(view.concat(newViews).flat());
-  };
-
-  return (
-    <>
-      <HotkeysDetector
-        onDetected={(stroke) => onDetected(stroke)}
-        keySet={KEY_SET}
-        strokeSet={STROKE_SET}
-      />
-      {view}
-    </>
-  );
-};
-
-export default HotkeysManager;

+ 100 - 0
apps/app/src/client/components/Hotkeys/HotkeysManager.spec.tsx

@@ -0,0 +1,100 @@
+import { act, cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+// Mock all subscriber components as simple render trackers with their binding definitions
+vi.mock('./Subscribers/EditPage', () => ({
+  EditPage: vi.fn(() => null),
+  hotkeyBindings: { keys: 'e', category: 'single' },
+}));
+vi.mock('./Subscribers/CreatePage', () => ({
+  CreatePage: vi.fn(() => null),
+  hotkeyBindings: { keys: 'c', category: 'single' },
+}));
+vi.mock('./Subscribers/FocusToGlobalSearch', () => ({
+  FocusToGlobalSearch: vi.fn(() => null),
+  hotkeyBindings: { keys: '/', category: 'single' },
+}));
+vi.mock('./Subscribers/ShowShortcutsModal', () => ({
+  ShowShortcutsModal: vi.fn(() => null),
+  hotkeyBindings: { keys: ['Control+/', 'Meta+/'], category: 'modifier' },
+}));
+vi.mock('./Subscribers/ShowStaffCredit', () => ({
+  ShowStaffCredit: vi.fn(() => null),
+  hotkeyBindings: {
+    keys: 'ArrowUp ArrowUp ArrowDown ArrowDown ArrowLeft ArrowRight ArrowLeft ArrowRight b a',
+    category: 'modifier',
+  },
+}));
+vi.mock('./Subscribers/SwitchToMirrorMode', () => ({
+  SwitchToMirrorMode: vi.fn(() => null),
+  hotkeyBindings: {
+    keys: 'x x b b a y a y ArrowDown ArrowLeft',
+    category: 'modifier',
+  },
+}));
+
+const { default: HotkeysManager } = await import('./HotkeysManager');
+const { EditPage } = await import('./Subscribers/EditPage');
+const { ShowShortcutsModal } = await import('./Subscribers/ShowShortcutsModal');
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+const pressKey = (key: string, options: Partial<KeyboardEventInit> = {}) => {
+  const event = new KeyboardEvent('keydown', {
+    key,
+    bubbles: true,
+    cancelable: true,
+    ...options,
+  });
+  // happy-dom does not wire ctrlKey/metaKey to getModifierState — override for tinykeys
+  Object.defineProperty(event, 'getModifierState', {
+    value: (mod: string) => {
+      if (mod === 'Control') return !!options.ctrlKey;
+      if (mod === 'Meta') return !!options.metaKey;
+      if (mod === 'Shift') return !!options.shiftKey;
+      if (mod === 'Alt') return !!options.altKey;
+      return false;
+    },
+  });
+  window.dispatchEvent(event);
+};
+
+describe('HotkeysManager', () => {
+  it('renders the corresponding subscriber when a single key is pressed', () => {
+    render(<HotkeysManager />);
+    act(() => {
+      pressKey('e');
+    });
+    expect(EditPage).toHaveBeenCalled();
+  });
+
+  it('renders the corresponding subscriber when a modifier key combo is pressed', () => {
+    render(<HotkeysManager />);
+    act(() => {
+      pressKey('/', { ctrlKey: true });
+    });
+    expect(ShowShortcutsModal).toHaveBeenCalled();
+  });
+
+  it('does NOT trigger single-key shortcut when target is an editable element', () => {
+    render(<HotkeysManager />);
+    const input = document.createElement('input');
+    document.body.appendChild(input);
+
+    act(() => {
+      input.dispatchEvent(
+        new KeyboardEvent('keydown', {
+          key: 'e',
+          bubbles: true,
+          cancelable: true,
+        }),
+      );
+    });
+    expect(EditPage).not.toHaveBeenCalled();
+
+    document.body.removeChild(input);
+  });
+});

+ 99 - 0
apps/app/src/client/components/Hotkeys/HotkeysManager.tsx

@@ -0,0 +1,99 @@
+import type { JSX } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { tinykeys } from 'tinykeys';
+
+import * as createPage from './Subscribers/CreatePage';
+import * as editPage from './Subscribers/EditPage';
+import * as focusToGlobalSearch from './Subscribers/FocusToGlobalSearch';
+import * as showShortcutsModal from './Subscribers/ShowShortcutsModal';
+import * as showStaffCredit from './Subscribers/ShowStaffCredit';
+import * as switchToMirrorMode from './Subscribers/SwitchToMirrorMode';
+
+export type HotkeyCategory = 'single' | 'modifier';
+
+export type HotkeyBindingDef = {
+  keys: string | string[];
+  category: HotkeyCategory;
+};
+
+type SubscriberComponent = React.ComponentType<{ onDeleteRender: () => void }>;
+
+type HotkeySubscriber = {
+  component: SubscriberComponent;
+  bindings: HotkeyBindingDef;
+};
+
+const subscribers: HotkeySubscriber[] = [
+  { component: editPage.EditPage, bindings: editPage.hotkeyBindings },
+  { component: createPage.CreatePage, bindings: createPage.hotkeyBindings },
+  {
+    component: focusToGlobalSearch.FocusToGlobalSearch,
+    bindings: focusToGlobalSearch.hotkeyBindings,
+  },
+  {
+    component: showShortcutsModal.ShowShortcutsModal,
+    bindings: showShortcutsModal.hotkeyBindings,
+  },
+  {
+    component: showStaffCredit.ShowStaffCredit,
+    bindings: showStaffCredit.hotkeyBindings,
+  },
+  {
+    component: switchToMirrorMode.SwitchToMirrorMode,
+    bindings: switchToMirrorMode.hotkeyBindings,
+  },
+];
+
+const isEditableTarget = (event: KeyboardEvent): boolean => {
+  const target = event.target as HTMLElement | null;
+  if (target == null) return false;
+  const { tagName } = target;
+  if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
+    return true;
+  }
+  return target.isContentEditable;
+};
+
+const HotkeysManager = (): JSX.Element => {
+  const [views, setViews] = useState<JSX.Element[]>([]);
+  const nextKeyRef = useRef(0);
+
+  const addView = useCallback((Component: SubscriberComponent) => {
+    const viewKey = String(nextKeyRef.current++);
+    const deleteRender = () => {
+      setViews((prev) => prev.filter((v) => v.key !== viewKey));
+    };
+    setViews((prev) => [
+      ...prev,
+      <Component key={viewKey} onDeleteRender={deleteRender} />,
+    ]);
+  }, []);
+
+  useEffect(() => {
+    const createHandler =
+      (component: SubscriberComponent, category: HotkeyCategory) =>
+      (event: KeyboardEvent) => {
+        if (category === 'single' && isEditableTarget(event)) return;
+        event.preventDefault();
+        addView(component);
+      };
+
+    const bindingMap: Record<string, (event: KeyboardEvent) => void> = {};
+    for (const { component, bindings } of subscribers) {
+      const handler = createHandler(component, bindings.category);
+      const keys = Array.isArray(bindings.keys)
+        ? bindings.keys
+        : [bindings.keys];
+      for (const key of keys) {
+        bindingMap[key] = handler;
+      }
+    }
+
+    const unsubscribe = tinykeys(window, bindingMap);
+    return unsubscribe;
+  }, [addView]);
+
+  return <>{views}</>;
+};
+
+export default HotkeysManager;

+ 0 - 32
apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,32 +0,0 @@
-import React, { useEffect } from 'react';
-import PropTypes from 'prop-types';
-
-import { useCurrentPagePath } from '~/states/page';
-import { usePageCreateModalActions } from '~/states/ui/modal/page-create';
-
-const CreatePage = React.memo((props) => {
-  const { open: openCreateModal } = usePageCreateModalActions();
-  const currentPath = useCurrentPagePath();
-
-  // setup effect
-  useEffect(() => {
-    openCreateModal(currentPath ?? '');
-
-    // remove this
-    props.onDeleteRender(this);
-  }, [currentPath, openCreateModal, props]);
-
-  return <></>;
-});
-
-CreatePage.propTypes = {
-  onDeleteRender: PropTypes.func.isRequired,
-};
-
-CreatePage.getHotkeyStrokes = () => {
-  return [['c']];
-};
-
-CreatePage.displayName = 'CreatePage';
-
-export default CreatePage;

+ 40 - 0
apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.spec.tsx

@@ -0,0 +1,40 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const mockOpen = vi.hoisted(() => vi.fn());
+
+vi.mock('~/states/page', () => ({
+  useCurrentPagePath: vi.fn(() => '/test/page'),
+}));
+vi.mock('~/states/ui/modal/page-create', () => ({
+  usePageCreateModalActions: vi.fn(() => ({ open: mockOpen })),
+}));
+
+const { CreatePage, hotkeyBindings } = await import('./CreatePage');
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('CreatePage', () => {
+  describe('hotkeyBindings', () => {
+    it('defines "c" key as single category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: 'c',
+        category: 'single',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('opens create modal with current page path and calls onDeleteRender', () => {
+      const onDeleteRender = vi.fn();
+
+      render(<CreatePage onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).toHaveBeenCalledWith('/test/page');
+      expect(onDeleteRender).toHaveBeenCalledOnce();
+    });
+  });
+});

+ 29 - 0
apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.tsx

@@ -0,0 +1,29 @@
+import { useEffect } from 'react';
+
+import { useCurrentPagePath } from '~/states/page';
+import { usePageCreateModalActions } from '~/states/ui/modal/page-create';
+
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
+type Props = {
+  onDeleteRender: () => void;
+};
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'c',
+  category: 'single',
+};
+
+const CreatePage = ({ onDeleteRender }: Props): null => {
+  const { open: openCreateModal } = usePageCreateModalActions();
+  const currentPath = useCurrentPagePath();
+
+  useEffect(() => {
+    openCreateModal(currentPath ?? '');
+    onDeleteRender();
+  }, [currentPath, openCreateModal, onDeleteRender]);
+
+  return null;
+};
+
+export { CreatePage };

+ 130 - 0
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.spec.tsx

@@ -0,0 +1,130 @@
+import { cleanup, render, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const mockStartEditing = vi.hoisted(() => vi.fn());
+const mockToastError = vi.hoisted(() => vi.fn());
+const mockUseIsEditable = vi.hoisted(() => vi.fn());
+const mockUseCurrentPagePath = vi.hoisted(() => vi.fn());
+const mockUseCurrentPathname = vi.hoisted(() => vi.fn());
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string, opts?: Record<string, string>) =>
+      `${key}:${JSON.stringify(opts)}`,
+  }),
+}));
+vi.mock('~/client/services/use-start-editing', () => ({
+  useStartEditing: () => mockStartEditing,
+}));
+vi.mock('~/client/util/toastr', () => ({
+  toastError: mockToastError,
+}));
+vi.mock('~/states/global', () => ({
+  useCurrentPathname: mockUseCurrentPathname,
+}));
+vi.mock('~/states/page', () => ({
+  useCurrentPagePath: mockUseCurrentPagePath,
+  useIsEditable: mockUseIsEditable,
+}));
+
+const { EditPage, hotkeyBindings } = await import('./EditPage');
+
+afterEach(() => {
+  cleanup();
+  vi.restoreAllMocks();
+});
+
+describe('EditPage', () => {
+  describe('hotkeyBindings', () => {
+    it('defines "e" key as single category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: 'e',
+        category: 'single',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('calls startEditing with current page path and then onDeleteRender', async () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseCurrentPagePath.mockReturnValue('/test/page');
+      mockUseCurrentPathname.mockReturnValue('/fallback');
+      mockStartEditing.mockResolvedValue(undefined);
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      await waitFor(() => {
+        expect(mockStartEditing).toHaveBeenCalledWith('/test/page');
+        expect(onDeleteRender).toHaveBeenCalledOnce();
+      });
+    });
+
+    it('falls back to currentPathname when currentPagePath is null', async () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseCurrentPagePath.mockReturnValue(null);
+      mockUseCurrentPathname.mockReturnValue('/fallback/path');
+      mockStartEditing.mockResolvedValue(undefined);
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      await waitFor(() => {
+        expect(mockStartEditing).toHaveBeenCalledWith('/fallback/path');
+      });
+    });
+
+    it('does not call startEditing when page is not editable', async () => {
+      mockUseIsEditable.mockReturnValue(false);
+      mockUseCurrentPagePath.mockReturnValue('/test/page');
+      mockUseCurrentPathname.mockReturnValue('/fallback');
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      // Give async useEffect time to execute
+      await waitFor(() => {
+        expect(mockStartEditing).not.toHaveBeenCalled();
+      });
+    });
+
+    it('does not call startEditing when a modal is open', async () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseCurrentPagePath.mockReturnValue('/test/page');
+      mockUseCurrentPathname.mockReturnValue('/fallback');
+
+      // Simulate an open Bootstrap modal in the DOM
+      // happy-dom does not fully support multi-class getElementsByClassName,
+      // so we spy on the boundary (DOM API) directly
+      const mockCollection = [document.createElement('div')];
+      vi.spyOn(document, 'getElementsByClassName').mockReturnValue(
+        mockCollection as unknown as HTMLCollectionOf<Element>,
+      );
+
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      await waitFor(() => {
+        expect(mockStartEditing).not.toHaveBeenCalled();
+      });
+    });
+
+    it('shows toast error when startEditing fails', async () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseCurrentPagePath.mockReturnValue('/failing/page');
+      mockUseCurrentPathname.mockReturnValue('/fallback');
+      mockStartEditing.mockRejectedValue(new Error('edit failed'));
+      const onDeleteRender = vi.fn();
+
+      render(<EditPage onDeleteRender={onDeleteRender} />);
+
+      await waitFor(() => {
+        expect(mockToastError).toHaveBeenCalledWith(
+          expect.stringContaining('toaster.create_failed'),
+        );
+        expect(onDeleteRender).toHaveBeenCalledOnce();
+      });
+    });
+  });
+});

+ 8 - 5
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx

@@ -6,10 +6,17 @@ import { toastError } from '~/client/util/toastr';
 import { useCurrentPathname } from '~/states/global';
 import { useCurrentPathname } from '~/states/global';
 import { useCurrentPagePath, useIsEditable } from '~/states/page';
 import { useCurrentPagePath, useIsEditable } from '~/states/page';
 
 
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
 type Props = {
 type Props = {
   onDeleteRender: () => void;
   onDeleteRender: () => void;
 };
 };
 
 
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'e',
+  category: 'single',
+};
+
 /**
 /**
  * Custom hook for edit page logic
  * Custom hook for edit page logic
  */
  */
@@ -68,8 +75,4 @@ const EditPage = (props: Props): null => {
   return null;
   return null;
 };
 };
 
 
-EditPage.getHotkeyStrokes = () => {
-  return [['e']];
-};
-
-export default EditPage;
+export { EditPage };

+ 69 - 0
apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.spec.tsx

@@ -0,0 +1,69 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const mockOpen = vi.hoisted(() => vi.fn());
+const mockUseIsEditable = vi.hoisted(() => vi.fn());
+const mockUseSearchModalStatus = vi.hoisted(() => vi.fn());
+
+vi.mock('~/states/page', () => ({
+  useIsEditable: mockUseIsEditable,
+}));
+vi.mock('~/features/search/client/states/modal/search', () => ({
+  useSearchModalStatus: mockUseSearchModalStatus,
+  useSearchModalActions: vi.fn(() => ({ open: mockOpen })),
+}));
+
+const { FocusToGlobalSearch, hotkeyBindings } = await import(
+  './FocusToGlobalSearch'
+);
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('FocusToGlobalSearch', () => {
+  describe('hotkeyBindings', () => {
+    it('defines "/" key as single category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: '/',
+        category: 'single',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('opens search modal when editable and not already opened, then calls onDeleteRender', () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseSearchModalStatus.mockReturnValue({ isOpened: false });
+      const onDeleteRender = vi.fn();
+
+      render(<FocusToGlobalSearch onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).toHaveBeenCalledOnce();
+      expect(onDeleteRender).toHaveBeenCalledOnce();
+    });
+
+    it('does not open search modal when not editable', () => {
+      mockUseIsEditable.mockReturnValue(false);
+      mockUseSearchModalStatus.mockReturnValue({ isOpened: false });
+      const onDeleteRender = vi.fn();
+
+      render(<FocusToGlobalSearch onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).not.toHaveBeenCalled();
+      expect(onDeleteRender).not.toHaveBeenCalled();
+    });
+
+    it('does not open search modal when already opened', () => {
+      mockUseIsEditable.mockReturnValue(true);
+      mockUseSearchModalStatus.mockReturnValue({ isOpened: true });
+      const onDeleteRender = vi.fn();
+
+      render(<FocusToGlobalSearch onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).not.toHaveBeenCalled();
+      expect(onDeleteRender).not.toHaveBeenCalled();
+    });
+  });
+});

+ 15 - 10
apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx → apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.tsx

@@ -6,12 +6,22 @@ import {
 } from '~/features/search/client/states/modal/search';
 } from '~/features/search/client/states/modal/search';
 import { useIsEditable } from '~/states/page';
 import { useIsEditable } from '~/states/page';
 
 
-const FocusToGlobalSearch = (props) => {
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
+type Props = {
+  onDeleteRender: () => void;
+};
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: '/',
+  category: 'single',
+};
+
+const FocusToGlobalSearch = ({ onDeleteRender }: Props): null => {
   const isEditable = useIsEditable();
   const isEditable = useIsEditable();
   const searchModalData = useSearchModalStatus();
   const searchModalData = useSearchModalStatus();
   const { open: openSearchModal } = useSearchModalActions();
   const { open: openSearchModal } = useSearchModalActions();
 
 
-  // setup effect
   useEffect(() => {
   useEffect(() => {
     if (!isEditable) {
     if (!isEditable) {
       return;
       return;
@@ -19,16 +29,11 @@ const FocusToGlobalSearch = (props) => {
 
 
     if (!searchModalData.isOpened) {
     if (!searchModalData.isOpened) {
       openSearchModal();
       openSearchModal();
-      // remove this
-      props.onDeleteRender();
+      onDeleteRender();
     }
     }
-  }, [isEditable, openSearchModal, props, searchModalData.isOpened]);
+  }, [isEditable, openSearchModal, onDeleteRender, searchModalData.isOpened]);
 
 
   return null;
   return null;
 };
 };
 
 
-FocusToGlobalSearch.getHotkeyStrokes = () => {
-  return [['/']];
-};
-
-export default FocusToGlobalSearch;
+export { FocusToGlobalSearch };

+ 61 - 0
apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.spec.tsx

@@ -0,0 +1,61 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const mockOpen = vi.hoisted(() => vi.fn());
+const mockUseShortcutsModalStatus = vi.hoisted(() => vi.fn());
+
+vi.mock('~/states/ui/modal/shortcuts', () => ({
+  useShortcutsModalStatus: mockUseShortcutsModalStatus,
+  useShortcutsModalActions: vi.fn(() => ({ open: mockOpen })),
+}));
+
+const { ShowShortcutsModal, hotkeyBindings } = await import(
+  './ShowShortcutsModal'
+);
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('ShowShortcutsModal', () => {
+  describe('hotkeyBindings', () => {
+    it('defines Ctrl+/ and Meta+/ as modifier category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: ['Control+/', 'Meta+/'],
+        category: 'modifier',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('opens shortcuts modal when not already opened and calls onDeleteRender', () => {
+      mockUseShortcutsModalStatus.mockReturnValue({ isOpened: false });
+      const onDeleteRender = vi.fn();
+
+      render(<ShowShortcutsModal onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).toHaveBeenCalledOnce();
+      expect(onDeleteRender).toHaveBeenCalledOnce();
+    });
+
+    it('does not open modal when already opened', () => {
+      mockUseShortcutsModalStatus.mockReturnValue({ isOpened: true });
+      const onDeleteRender = vi.fn();
+
+      render(<ShowShortcutsModal onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).not.toHaveBeenCalled();
+      expect(onDeleteRender).not.toHaveBeenCalled();
+    });
+
+    it('does not open modal when status is null', () => {
+      mockUseShortcutsModalStatus.mockReturnValue(null);
+      const onDeleteRender = vi.fn();
+
+      render(<ShowShortcutsModal onDeleteRender={onDeleteRender} />);
+
+      expect(mockOpen).not.toHaveBeenCalled();
+    });
+  });
+});

+ 12 - 12
apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx

@@ -1,20 +1,25 @@
-import React, { type JSX, useEffect } from 'react';
+import { useEffect } from 'react';
 
 
 import {
 import {
   useShortcutsModalActions,
   useShortcutsModalActions,
   useShortcutsModalStatus,
   useShortcutsModalStatus,
 } from '~/states/ui/modal/shortcuts';
 } from '~/states/ui/modal/shortcuts';
 
 
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
 type Props = {
 type Props = {
   onDeleteRender: () => void;
   onDeleteRender: () => void;
 };
 };
-const ShowShortcutsModal = (props: Props): JSX.Element => {
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: ['Control+/', 'Meta+/'],
+  category: 'modifier',
+};
+
+const ShowShortcutsModal = ({ onDeleteRender }: Props): null => {
   const status = useShortcutsModalStatus();
   const status = useShortcutsModalStatus();
   const { open } = useShortcutsModalActions();
   const { open } = useShortcutsModalActions();
 
 
-  const { onDeleteRender } = props;
-
-  // setup effect
   useEffect(() => {
   useEffect(() => {
     if (status == null) {
     if (status == null) {
       return;
       return;
@@ -22,16 +27,11 @@ const ShowShortcutsModal = (props: Props): JSX.Element => {
 
 
     if (!status.isOpened) {
     if (!status.isOpened) {
       open();
       open();
-      // remove this
       onDeleteRender();
       onDeleteRender();
     }
     }
   }, [onDeleteRender, open, status]);
   }, [onDeleteRender, open, status]);
 
 
-  return <></>;
-};
-
-ShowShortcutsModal.getHotkeyStrokes = () => {
-  return [['/+ctrl'], ['/+meta']];
+  return null;
 };
 };
 
 
-export default ShowShortcutsModal;
+export { ShowShortcutsModal };

+ 0 - 30
apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.jsx

@@ -1,30 +0,0 @@
-import PropTypes from 'prop-types';
-
-import StaffCredit from '../../StaffCredit/StaffCredit';
-
-const ShowStaffCredit = (props) => {
-  return <StaffCredit onClosed={() => props.onDeleteRender(this)} />;
-};
-
-ShowStaffCredit.propTypes = {
-  onDeleteRender: PropTypes.func.isRequired,
-};
-
-ShowStaffCredit.getHotkeyStrokes = () => {
-  return [
-    [
-      'ArrowUp',
-      'ArrowUp',
-      'ArrowDown',
-      'ArrowDown',
-      'ArrowLeft',
-      'ArrowRight',
-      'ArrowLeft',
-      'ArrowRight',
-      'b',
-      'a',
-    ],
-  ];
-};
-
-export default ShowStaffCredit;

+ 39 - 0
apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.spec.tsx

@@ -0,0 +1,39 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../StaffCredit/StaffCredit', () => ({
+  default: vi.fn(() => <div data-testid="staff-credit">StaffCredit</div>),
+}));
+
+const { default: StaffCredit } = await import('../../StaffCredit/StaffCredit');
+const { ShowStaffCredit, hotkeyBindings } = await import('./ShowStaffCredit');
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('ShowStaffCredit', () => {
+  describe('hotkeyBindings', () => {
+    it('defines the Konami code sequence as modifier category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: 'ArrowUp ArrowUp ArrowDown ArrowDown ArrowLeft ArrowRight ArrowLeft ArrowRight b a',
+        category: 'modifier',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('renders StaffCredit with onDeleteRender passed as onClosed', () => {
+      const onDeleteRender = vi.fn();
+
+      render(<ShowStaffCredit onDeleteRender={onDeleteRender} />);
+
+      expect(StaffCredit).toHaveBeenCalledWith(
+        expect.objectContaining({ onClosed: onDeleteRender }),
+        expect.anything(),
+      );
+      expect(screen.getByTestId('staff-credit')).toBeDefined();
+    });
+  });
+});

+ 19 - 0
apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.tsx

@@ -0,0 +1,19 @@
+import type { JSX } from 'react';
+
+import StaffCredit from '../../StaffCredit/StaffCredit';
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
+type Props = {
+  onDeleteRender: () => void;
+};
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'ArrowUp ArrowUp ArrowDown ArrowDown ArrowLeft ArrowRight ArrowLeft ArrowRight b a',
+  category: 'modifier',
+};
+
+const ShowStaffCredit = ({ onDeleteRender }: Props): JSX.Element => {
+  return <StaffCredit onClosed={onDeleteRender} />;
+};
+
+export { ShowStaffCredit };

+ 0 - 24
apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx

@@ -1,24 +0,0 @@
-import React, { useEffect } from 'react';
-import PropTypes from 'prop-types';
-
-const SwitchToMirrorMode = (props) => {
-  // setup effect
-  useEffect(() => {
-    document.body.classList.add('mirror');
-
-    // remove this
-    props.onDeleteRender(this);
-  }, [props]);
-
-  return <></>;
-};
-
-SwitchToMirrorMode.propTypes = {
-  onDeleteRender: PropTypes.func.isRequired,
-};
-
-SwitchToMirrorMode.getHotkeyStrokes = () => {
-  return [['x', 'x', 'b', 'b', 'a', 'y', 'a', 'y', 'ArrowDown', 'ArrowLeft']];
-};
-
-export default SwitchToMirrorMode;

+ 33 - 0
apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.spec.tsx

@@ -0,0 +1,33 @@
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { hotkeyBindings, SwitchToMirrorMode } from './SwitchToMirrorMode';
+
+afterEach(() => {
+  cleanup();
+  document.body.classList.remove('mirror');
+});
+
+describe('SwitchToMirrorMode', () => {
+  describe('hotkeyBindings', () => {
+    it('defines the Konami-style key sequence as modifier category', () => {
+      expect(hotkeyBindings).toEqual({
+        keys: 'x x b b a y a y ArrowDown ArrowLeft',
+        category: 'modifier',
+      });
+    });
+  });
+
+  describe('behavior', () => {
+    it('adds "mirror" class to document.body and calls onDeleteRender', () => {
+      const onDeleteRender = vi.fn();
+
+      expect(document.body.classList.contains('mirror')).toBe(false);
+
+      render(<SwitchToMirrorMode onDeleteRender={onDeleteRender} />);
+
+      expect(document.body.classList.contains('mirror')).toBe(true);
+      expect(onDeleteRender).toHaveBeenCalledOnce();
+    });
+  });
+});

+ 23 - 0
apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.tsx

@@ -0,0 +1,23 @@
+import { useEffect } from 'react';
+
+import type { HotkeyBindingDef } from '../HotkeysManager';
+
+type Props = {
+  onDeleteRender: () => void;
+};
+
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'x x b b a y a y ArrowDown ArrowLeft',
+  category: 'modifier',
+};
+
+const SwitchToMirrorMode = ({ onDeleteRender }: Props): null => {
+  useEffect(() => {
+    document.body.classList.add('mirror');
+    onDeleteRender();
+  }, [onDeleteRender]);
+
+  return null;
+};
+
+export { SwitchToMirrorMode };

+ 2 - 2
apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.module.scss

@@ -1,5 +1,5 @@
-.modal-notification :global {
-  .page-title {
+.modal-notification {
+  :global(.page-title) {
     font-size: 14px;
     font-size: 14px;
   }
   }
 }
 }

+ 1 - 2
apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx

@@ -1,11 +1,10 @@
-import React from 'react';
 import { type HasObjectId, isPopulated } from '@growi/core';
 import { type HasObjectId, isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import type { IPageBulkExportJobHasId } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import type { IPageBulkExportJobHasId } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
-import * as pageBulkExportJobSerializers from '~/models/serializers/in-app-notification-snapshot/page-bulk-export-job';
+import * as pageBulkExportJobSerializers from '~/models/serializers/in-app-notification-snapshot/page-bulk-export-job-client';
 
 
 import type { ModelNotificationUtils } from '.';
 import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';

+ 3 - 3
apps/app/src/client/components/InstallerForm.module.scss

@@ -1,8 +1,8 @@
-@use '~/styles/atoms/placeholders/buttons';
+@use 'styles/atoms/placeholders/buttons';
 
 
 :root {
 :root {
-  .installer-form :global {
-    .btn-register {
+  .installer-form {
+    :global(.btn-register) {
       @extend %btn-nologin;
       @extend %btn-nologin;
       @extend %btn-register;
       @extend %btn-register;
     }
     }

+ 1 - 1
apps/app/src/client/components/ItemsTree/ItemsTreeContentSkeleton.module.scss

@@ -1,4 +1,4 @@
-@use '~/styles/mixins';
+@use 'styles/mixins';
 
 
 .text-skeleton-level1 {
 .text-skeleton-level1 {
   @include mixins.grw-skeleton-text($font-size:16px, $line-height: 40px);
   @include mixins.grw-skeleton-text($font-size:16px, $line-height: 40px);

+ 16 - 16
apps/app/src/client/components/LoginForm/LoginForm.module.scss

@@ -1,7 +1,7 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/atoms/placeholders/buttons';
+@use 'styles/atoms/placeholders/buttons';
 
 
-.login-form :global {
+.login-form {
   //
   //
   // deactivated in order to fix https://redmine.weseek.co.jp/issues/143531 -- 2024.04.02 Yuki Takei
   // deactivated in order to fix https://redmine.weseek.co.jp/issues/143531 -- 2024.04.02 Yuki Takei
   //
   //
@@ -12,17 +12,17 @@
   //   height: 0% !important;
   //   height: 0% !important;
   // }
   // }
 
 
-  .collapse-external-auth {
+  :global(.collapse-external-auth) {
     overflow: hidden;
     overflow: hidden;
   }
   }
 
 
-  .link-growi-org {
+  :global(.link-growi-org) {
     position: absolute;
     position: absolute;
     bottom: 9px;
     bottom: 9px;
     z-index: 3;
     z-index: 3;
   }
   }
 
 
-  .text-line {
+  :global(.text-line) {
     &::before,
     &::before,
     &::after {
     &::after {
       flex-grow: 1;
       flex-grow: 1;
@@ -34,11 +34,11 @@
   }
   }
 
 
 
 
-  .ldap-space {
+  :global(.ldap-space) {
     padding-right: 76px;
     padding-right: 76px;
   }
   }
 
 
-  .input-ldap {
+  :global(.input-ldap) {
     position: absolute;
     position: absolute;
     top: 4px;
     top: 4px;
     right: 5px;
     right: 5px;
@@ -48,47 +48,47 @@
 
 
 // Button colors
 // Button colors
 :root {
 :root {
-  .login-form :global {
+  .login-form {
 
 
-    .btn {
+    :global(.btn) {
       @extend %btn-nologin;
       @extend %btn-nologin;
     }
     }
 
 
-    .btn-register {
+    :global(.btn-register) {
       @extend %btn-register;
       @extend %btn-register;
     }
     }
 
 
-    .btn-login {
+    :global(.btn-login) {
       --bs-btn-bg: #{rgba(#204986, 0.6)};
       --bs-btn-bg: #{rgba(#204986, 0.6)};
       --bs-btn-hover-bg: #{rgba(#204986, 0.8)};
       --bs-btn-hover-bg: #{rgba(#204986, 0.8)};
       --bs-btn-active-bg: #{rgba(#204986, 0.8)};
       --bs-btn-active-bg: #{rgba(#204986, 0.8)};
     }
     }
 
 
-    .btn-function {
+    :global(.btn-function) {
       --bs-btn-bg: #{rgba(bs.$gray-800, 0.8)};
       --bs-btn-bg: #{rgba(bs.$gray-800, 0.8)};
       --bs-btn-hover-bg: #{rgba(bs.$gray-800, 0.5)};
       --bs-btn-hover-bg: #{rgba(bs.$gray-800, 0.5)};
       --bs-btn-active-bg: #{rgba(bs.$gray-800, 0.5)};
       --bs-btn-active-bg: #{rgba(bs.$gray-800, 0.5)};
     }
     }
 
 
-    .btn-auth-google {
+    :global(.btn-auth-google) {
       --bs-btn-bg: #{rgba(#4285F4, 0.4)};
       --bs-btn-bg: #{rgba(#4285F4, 0.4)};
       --bs-btn-hover-bg: #{rgba(#4285F4, 0.8)};
       --bs-btn-hover-bg: #{rgba(#4285F4, 0.8)};
       --bs-btn-active-bg: #{rgba(#4285F4, 0.8)};
       --bs-btn-active-bg: #{rgba(#4285F4, 0.8)};
     }
     }
 
 
-    .btn-auth-github {
+    :global(.btn-auth-github) {
       --bs-btn-bg: #{rgba(#403D3E, 0.4)};
       --bs-btn-bg: #{rgba(#403D3E, 0.4)};
       --bs-btn-hover-bg: #{rgba(#403D3E, 0.7)};
       --bs-btn-hover-bg: #{rgba(#403D3E, 0.7)};
       --bs-btn-active-bg: #{rgba(#403D3E, 0.7)};
       --bs-btn-active-bg: #{rgba(#403D3E, 0.7)};
     }
     }
 
 
-    .btn-auth-oidc {
+    :global(.btn-auth-oidc) {
       --bs-btn-bg: #{rgba(#835B1A, 0.4)};
       --bs-btn-bg: #{rgba(#835B1A, 0.4)};
       --bs-btn-hover-bg: #{rgba(#835B1A, 0.8)};
       --bs-btn-hover-bg: #{rgba(#835B1A, 0.8)};
       --bs-btn-active-bg: #{rgba(#835B1A, 0.8)};
       --bs-btn-active-bg: #{rgba(#835B1A, 0.8)};
     }
     }
 
 
-    .btn-auth-saml {
+    :global(.btn-auth-saml) {
       --bs-btn-bg: #{rgba(#138957, 0.4)};
       --bs-btn-bg: #{rgba(#138957, 0.4)};
       --bs-btn-hover-bg: #{rgba(#138957, 0.7)};
       --bs-btn-hover-bg: #{rgba(#138957, 0.7)};
       --bs-btn-active-bg: #{rgba(#138957, 0.7)};
       --bs-btn-active-bg: #{rgba(#138957, 0.7)};

+ 12 - 12
apps/app/src/client/components/Me/AccessTokenScopeList.module.scss

@@ -1,35 +1,35 @@
 $baseMargin: 20px;
 $baseMargin: 20px;
 
 
-.access-token-scope-list :global {
-  .indentation {
-    &.indentation-level-1 {
+.access-token-scope-list {
+  :global(.indentation) {
+    &:global(.indentation-level-1) {
       margin-left: $baseMargin;
       margin-left: $baseMargin;
     }
     }
-    &.indentation-level-2 {
+    &:global(.indentation-level-2) {
       margin-left: $baseMargin * 2;
       margin-left: $baseMargin * 2;
     }
     }
-    &.indentation-level-3 {
+    &:global(.indentation-level-3) {
       margin-left: $baseMargin * 3;
       margin-left: $baseMargin * 3;
     }
     }
-    &.indentation-level-4 {
+    &:global(.indentation-level-4) {
       margin-left: $baseMargin * 4;
       margin-left: $baseMargin * 4;
     }
     }
-    &.indentation-level-5 {
+    &:global(.indentation-level-5) {
       margin-left: $baseMargin * 5;
       margin-left: $baseMargin * 5;
     }
     }
-    &.indentation-level-6 {
+    &:global(.indentation-level-6) {
       margin-left: $baseMargin * 6;
       margin-left: $baseMargin * 6;
     }
     }
-    &.indentation-level-7 {
+    &:global(.indentation-level-7) {
       margin-left: $baseMargin * 7;
       margin-left: $baseMargin * 7;
     }
     }
-    &.indentation-level-8 {
+    &:global(.indentation-level-8) {
       margin-left: $baseMargin * 8;
       margin-left: $baseMargin * 8;
     }
     }
-    &.indentation-level-9 {
+    &:global(.indentation-level-9) {
       margin-left: $baseMargin * 9;
       margin-left: $baseMargin * 9;
     }
     }
-    &.indentation-level-10 {
+    &:global(.indentation-level-10) {
       margin-left: $baseMargin * 10;
       margin-left: $baseMargin * 10;
     }
     }
   }
   }

Some files were not shown because too many files changed in this diff