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

Merge pull request #10907 from growilabs/master

Release v7.5.0
mergify[bot] 3 дней назад
Родитель
Сommit
6dcba21881
100 измененных файлов с 6308 добавлено и 624 удалено
  1. 83 0
      .claude/commands/create-next-version-branch.md
  2. 32 2
      .claude/commands/kiro/spec-cleanup.md
  3. 21 0
      .claude/rules/coding-style.md
  4. 33 2
      .claude/settings.json
  5. 46 1
      .claude/skills/monorepo-overview/SKILL.md
  6. 1 1
      .claude/skills/tech-stack/SKILL.md
  7. 1 1
      .devcontainer/app/devcontainer.json
  8. 0 2
      .github/dependabot.yml
  9. 5 5
      .github/mergify.yml
  10. 15 34
      .github/workflows/ci-app-prod.yml
  11. 3 3
      .github/workflows/ci-app.yml
  12. 4 4
      .github/workflows/ci-pdf-converter.yml
  13. 4 4
      .github/workflows/ci-slackbot-proxy.yml
  14. 2 2
      .github/workflows/release-rc.yml
  15. 7 12
      .github/workflows/release-subpackages.yml
  16. 1 1
      .github/workflows/reusable-app-build-image.yml
  17. 22 27
      .github/workflows/reusable-app-prod.yml
  18. 0 88
      .github/workflows/reusable-app-reg-suit.yml
  19. 1 0
      .gitignore
  20. 466 0
      .kiro/specs/auto-scroll/design.md
  21. 91 0
      .kiro/specs/auto-scroll/requirements.md
  22. 246 0
      .kiro/specs/auto-scroll/research.md
  23. 24 0
      .kiro/specs/auto-scroll/spec.json
  24. 154 0
      .kiro/specs/auto-scroll/tasks.md
  25. 268 0
      .kiro/specs/collaborative-editor/design.md
  26. 79 0
      .kiro/specs/collaborative-editor/requirements.md
  27. 69 0
      .kiro/specs/collaborative-editor/research.md
  28. 22 0
      .kiro/specs/collaborative-editor/spec.json
  29. 3 0
      .kiro/specs/collaborative-editor/tasks.md
  30. 153 0
      .kiro/specs/hotkeys/design.md
  31. 101 0
      .kiro/specs/hotkeys/requirements.md
  32. 23 0
      .kiro/specs/hotkeys/spec.json
  33. 29 0
      .kiro/specs/hotkeys/tasks.md
  34. 108 0
      .kiro/specs/news-inappnotification/requirements.md
  35. 22 0
      .kiro/specs/news-inappnotification/spec.json
  36. 233 0
      .kiro/specs/official-docker-image/design.md
  37. 82 0
      .kiro/specs/official-docker-image/requirements.md
  38. 288 0
      .kiro/specs/official-docker-image/research.md
  39. 22 0
      .kiro/specs/official-docker-image/spec.json
  40. 193 0
      .kiro/specs/official-docker-image/tasks.md
  41. 284 0
      .kiro/specs/presentation/design.md
  42. 26 0
      .kiro/specs/presentation/requirements.md
  43. 84 0
      .kiro/specs/presentation/research.md
  44. 23 0
      .kiro/specs/presentation/spec.json
  45. 359 0
      .kiro/specs/suggest-path/design.md
  46. 77 0
      .kiro/specs/suggest-path/requirements.md
  47. 145 0
      .kiro/specs/suggest-path/research.md
  48. 23 0
      .kiro/specs/suggest-path/spec.json
  49. 82 0
      .kiro/specs/suggest-path/tasks.md
  50. 14 2
      .kiro/steering/structure.md
  51. 53 2
      .kiro/steering/tech.md
  52. 0 2
      .npmrc
  53. 1 2
      AGENTS.md
  54. 1 1
      README.md
  55. 1 1
      README_JP.md
  56. 74 0
      apps/app/.claude/rules/package-dependencies.md
  57. 16 2
      apps/app/.claude/skills/app-commands/SKILL.md
  58. 127 0
      apps/app/.claude/skills/build-optimization/SKILL.md
  59. 90 0
      apps/app/.claude/skills/learned/fix-broken-next-symlinks/SKILL.md
  60. 116 0
      apps/app/.claude/skills/vendor-styles-components/SKILL.md
  61. 2 0
      apps/app/.env.development
  62. 4 0
      apps/app/.gitignore
  63. 8 0
      apps/app/AGENTS.md
  64. 30 0
      apps/app/bin/assemble-prod.sh
  65. 44 0
      apps/app/bin/check-next-symlinks.sh
  66. 60 0
      apps/app/bin/measure-chunk-stats.sh
  67. 4 0
      apps/app/bin/openapi/definition-apiv3.js
  68. 1 0
      apps/app/bin/openapi/generate-spec-apiv3.sh
  69. 31 0
      apps/app/bin/postbuild-server.ts
  70. 2 8
      apps/app/config/next-i18next.config.js
  71. 79 51
      apps/app/docker/Dockerfile
  72. 74 4
      apps/app/docker/Dockerfile.dockerignore
  73. 10 0
      apps/app/docker/README.md
  74. 2 0
      apps/app/docker/codebuild/buildspec.yml
  75. 0 18
      apps/app/docker/docker-entrypoint.sh
  76. 358 0
      apps/app/docker/docker-entrypoint.spec.ts
  77. 265 0
      apps/app/docker/docker-entrypoint.ts
  78. 0 5
      apps/app/next-env.d.ts
  79. 0 173
      apps/app/next.config.js
  80. 26 0
      apps/app/next.config.prod.cjs
  81. 146 0
      apps/app/next.config.ts
  82. 60 52
      apps/app/package.json
  83. 1 1
      apps/app/playwright.config.ts
  84. 16 3
      apps/app/public/static/locales/en_US/admin.json
  85. 25 1
      apps/app/public/static/locales/en_US/translation.json
  86. 16 3
      apps/app/public/static/locales/fr_FR/admin.json
  87. 25 1
      apps/app/public/static/locales/fr_FR/translation.json
  88. 16 3
      apps/app/public/static/locales/ja_JP/admin.json
  89. 25 1
      apps/app/public/static/locales/ja_JP/translation.json
  90. 16 3
      apps/app/public/static/locales/ko_KR/admin.json
  91. 25 1
      apps/app/public/static/locales/ko_KR/translation.json
  92. 16 3
      apps/app/public/static/locales/zh_CN/admin.json
  93. 25 1
      apps/app/public/static/locales/zh_CN/translation.json
  94. 0 26
      apps/app/regconfig.json
  95. 21 11
      apps/app/src/client/components/Admin/App/AzureSetting.tsx
  96. 64 39
      apps/app/src/client/components/Admin/App/FileUploadSetting.tsx
  97. 18 11
      apps/app/src/client/components/Admin/App/GcsSetting.tsx
  98. 21 1
      apps/app/src/client/components/Admin/AuditLog/AuditLogDisableMode.tsx
  99. 214 0
      apps/app/src/client/components/Admin/AuditLog/AuditLogExportModal.tsx
  100. 5 3
      apps/app/src/client/components/Admin/AuditLog/DateRangePicker.tsx

+ 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 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**:
 - Check that tasks are marked complete `[x]` in tasks.md
 - 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
    - 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
 
 **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: 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**:
 - Group related sections (e.g., all "delete implementation details" decisions)
 - 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
    - 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)
    - Add `cleanup_completed: true` flag
    - 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)
 - ✅ requirements.md: Simplified 6 requirements (350 lines → 180 lines)
 - ✅ design.md: Removed 4 sections, added constraints + salvaged content (250 lines removed, 100 added)
+- ✅ requirements.md: Translated mismatched sections to {target_language}
 
 ### Information Salvaged
 - Implementation discoveries from validation-report.md → research.md
@@ -196,7 +226,7 @@ For each file and section identified in Step 2, ask:
 ## Critical Constraints
 
 - **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
 - **Balance brevity with completeness**: Remove redundancy but keep essential context
 - **Interactive workflow**: Pause for user input rather than making assumptions

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

@@ -201,6 +201,27 @@ Implemented react-window for virtualizing page tree
 to improve performance with 10k+ pages.
 ```
 
+## Cross-Platform Compatibility
+
+GROWI must work on Windows, macOS, and Linux. Never use platform-specific shell commands in npm scripts.
+
+```json
+// ❌ WRONG: Unix-only commands in npm scripts
+"clean": "rm -rf dist",
+"copy": "cp src/foo.ts dist/foo.ts",
+"move": "mv src dist"
+
+// ✅ CORRECT: Cross-platform tools
+"clean": "rimraf dist",
+"copy": "node -e \"require('fs').cpSync('src/foo.ts','dist/foo.ts')\"",
+"move": "node -e \"require('fs').renameSync('src','dist')\""
+```
+
+**Rules**:
+- Use `rimraf` instead of `rm -rf`
+- Use Node.js one-liners or cross-platform tools (`cpy-cli`, `cpx2`) instead of `cp`, `mv`, `echo`, `ls`
+- Never assume a POSIX shell in npm scripts
+
 ## Code Quality Checklist
 
 Before marking work complete:

+ 33 - 2
.claude/settings.json

@@ -1,4 +1,36 @@
 {
+  "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(pnpm ls *)",
+      "Bash(pnpm why *)",
+      "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": {
     "SessionStart": [
       {
@@ -17,8 +49,7 @@
           {
             "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",
-            "timeout": 30,
-            "description": "Auto-format edited files in apps/* and packages/* with Biome"
+            "timeout": 30
           }
         ]
       }

+ 46 - 1
.claude/skills/monorepo-overview/SKILL.md

@@ -64,6 +64,34 @@ turbo run test --filter @growi/app
 turbo run lint --filter @growi/core
 ```
 
+### Build Order Management
+
+Build dependencies in this monorepo are **not** declared with `dependsOn: ["^build"]` (the automatic workspace-dependency mode). Instead, they are declared **explicitly** — either in the root `turbo.json` for legacy entries, or in per-package `turbo.json` files for newer packages.
+
+**When to update**: whenever a package gains a new workspace dependency on another buildable package (one that produces a `dist/`), declare the build-order dependency explicitly. Without it, Turborepo may build in the wrong order, causing missing `dist/` files or type errors.
+
+**Pattern — per-package `turbo.json`** (preferred for new dependencies):
+
+```json
+// packages/my-package/turbo.json
+{
+  "extends": ["//"],
+  "tasks": {
+    "build": { "dependsOn": ["@growi/some-dep#build"] },
+    "dev":   { "dependsOn": ["@growi/some-dep#dev"] }
+  }
+}
+```
+
+- `"extends": ["//"]` inherits all root task definitions; only add the extra `dependsOn`
+- Keep root `turbo.json` clean — package-level overrides live with the package that owns the dependency
+- For packages with multiple tasks (watch, lint, test), mirror the dependency in each relevant task
+
+**Existing examples**:
+- `packages/slack/turbo.json` — `build`/`dev` depend on `@growi/logger`
+- `packages/remark-attachment-refs/turbo.json` — all tasks depend on `@growi/core`, `@growi/logger`, `@growi/remark-growi-directive`, `@growi/ui`
+- Root `turbo.json` — `@growi/ui#build` depends on `@growi/core#build` (pre-dates the per-package pattern)
+
 ## Architectural Principles
 
 ### 1. Feature-Based Architecture (Recommended)
@@ -99,11 +127,28 @@ This enables better code splitting and prevents server-only code from being bund
 
 Common code should be extracted to `packages/`:
 
-- **core**: Utilities, constants, type definitions
+- **core**: Domain hub (see below)
 - **ui**: Reusable React components
 - **editor**: Markdown editor
 - **pluginkit**: Plugin system framework
 
+#### @growi/core — Domain & Utilities Hub
+
+`@growi/core` is the foundational shared package depended on by all other packages (10 consumers). Its responsibilities:
+
+- **Domain type definitions** — Single source of truth for cross-package interfaces (`IPage`, `IUser`, `IRevision`, `Ref<T>`, `HasObjectId`, etc.)
+- **Cross-cutting utilities** — Pure functions for page path validation, ObjectId checks, serialization (e.g., `serializeUserSecurely()`)
+- **System constants** — File types, plugin configs, scope enums
+- **Global type augmentations** — Runtime/polyfill type declarations visible to all consumers (e.g., `RegExp.escape()` via `declare global` in `index.ts`)
+
+Key patterns:
+
+1. **Shared types and global augmentations go in `@growi/core`** — Not duplicated per-package. `declare global` in `index.ts` propagates to all consumers through the module graph.
+2. **Subpath exports for granular imports** — `@growi/core/dist/utils/page-path-utils` instead of barrel imports from root.
+3. **Minimal runtime dependencies** — Only `bson-objectid`; ~70% types. Safe to import from both server and client contexts.
+4. **Server-specific interfaces are namespaced** — Under `interfaces/server/`.
+5. **Dual format (ESM + CJS)** — Built via Vite with `preserveModules: true` and `vite-plugin-dts` (`copyDtsFiles: true`).
+
 ## Version Management with Changeset
 
 GROWI uses **Changesets** for version management and release notes:

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

@@ -38,7 +38,7 @@ user-invocable: false
 ## Build & Development Tools
 
 ### 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
 - **Turborepo** ^2.1.3 - Build system with caching and parallelization

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

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

+ 0 - 2
.github/dependabot.yml

@@ -24,8 +24,6 @@ updates:
       prefix: ci
       include: scope
     ignore:
-      - dependency-name: escape-string-regexp
-      - dependency-name: string-width
       - dependency-name: "@handsontable/react"
       - dependency-name: handsontable
       - dependency-name: typeorm

+ 5 - 5
.github/mergify.yml

@@ -6,17 +6,17 @@ queue_rules:
       - check-success ~= ci-app-launch-dev
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node20 /
+      - -check-failure ~= test-prod-node24 /
     merge_conditions:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test
       - 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-slackbot-
-      - -check-failure ~= test-prod-node20 /
+      - -check-failure ~= test-prod-node24 /
 
 pull_request_rules:
   - name: Automatic queue to merge

+ 15 - 34
.github/workflows/ci-app-prod.yml

@@ -9,7 +9,6 @@ on:
       - .github/mergify.yml
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
-      - .github/workflows/reusable-app-reg-suit.yml
       - tsconfig.base.json
       - turbo.json
       - pnpm-lock.yaml
@@ -23,7 +22,6 @@ on:
       - .github/mergify.yml
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
-      - .github/workflows/reusable-app-reg-suit.yml
       - tsconfig.base.json
       - pnpm-lock.yaml
       - turbo.json
@@ -39,22 +37,21 @@ concurrency:
 
 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@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: 22.x
+  #     skip-e2e-test: true
+  #   secrets:
+  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-  test-prod-node20:
+  test-prod-node24:
     uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
     if: |
       ( github.event_name == 'push'
@@ -63,23 +60,7 @@ jobs:
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
-      node-version: 20.x
+      node-version: 24.x
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
     secrets:
       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:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
       - uses: actions/checkout@v4
@@ -92,7 +92,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
         mongodb-version: ['6.0', '8.0']
 
     services:
@@ -157,7 +157,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
         mongodb-version: ['6.0', '8.0']
 
     services:

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

@@ -29,7 +29,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -65,7 +65,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -104,7 +104,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -142,7 +142,7 @@ jobs:
     - name: Assembling all dependencies
       run: |
         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
 
     - name: pnpm run start:prod:ci

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

@@ -30,7 +30,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -85,7 +85,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     services:
       mysql:
@@ -163,7 +163,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     services:
       mysql:
@@ -211,7 +211,7 @@ jobs:
     - name: Assembling all dependencies
       run: |
         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
 
     - 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}}
 
   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:
       image-name: growilabs/growi
       tag-temporary: latest-rc
@@ -47,7 +47,7 @@ jobs:
   publish-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:
       tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io

+ 7 - 12
.github/workflows/release-subpackages.yml

@@ -14,6 +14,11 @@ on:
     branches:
       - master
 
+permissions:
+  id-token: write
+  contents: write
+  pull-requests: write
+
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}
   cancel-in-progress: true
@@ -32,7 +37,7 @@ jobs:
 
     - uses: actions/setup-node@v4
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
 
     - name: Install dependencies
@@ -40,14 +45,6 @@ jobs:
         pnpm add turbo --global
         pnpm install --frozen-lockfile
 
-    - name: Setup .npmrc
-      run: |
-        cat << EOF > "$HOME/.npmrc"
-          //registry.npmjs.org/:_authToken=$NPM_TOKEN
-        EOF
-      env:
-        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
-
     - name: Retrieve changesets information
       id: changesets-status
       run: |
@@ -61,7 +58,6 @@ jobs:
         pnpm run release-subpackages:snapshot
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
 
 
   release-subpackages:
@@ -75,7 +71,7 @@ jobs:
 
     - uses: actions/setup-node@v4
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
 
     - name: Install dependencies
@@ -92,4 +88,3 @@ jobs:
         publish: pnpm run release-subpackages
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

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

@@ -48,7 +48,7 @@ jobs:
         projectName: growi-official-image-builder
       env:
         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__environmentVariablesOverride: '[
           { "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:
         required: true
         type: string
-        default: 22.x
+        default: 24.x
       skip-e2e-test:
         type: boolean
         default: false
@@ -57,17 +57,18 @@ jobs:
       env:
         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
       id: archive-prod-files
       run: |
         tar -zcf production.tar.gz --exclude ./apps/app/.next/cache \
           package.json \
+          node_modules \
           apps/app/.next \
           apps/app/config \
           apps/app/dist \
@@ -76,6 +77,7 @@ jobs:
           apps/app/tmp \
           apps/app/.env.production* \
           apps/app/node_modules \
+          apps/app/next.config.js \
           apps/app/package.json
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
 
@@ -124,30 +126,22 @@ jobs:
           discovery.type: single-node
 
     steps:
-    - uses: actions/checkout@v4
-
-    - uses: pnpm/action-setup@v4
-
     - uses: actions/setup-node@v4
       with:
         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
       uses: actions/download-artifact@v4
       with:
         name: Production Files (node${{ inputs.node-version }})
 
-    - name: Extract procution files
+    - name: Extract production files
       run: |
         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
       working-directory: ./apps/app
       run: |
@@ -179,7 +173,7 @@ jobs:
     container:
       # Match the Playwright version
       # 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:
       fail-fast: false
@@ -223,14 +217,14 @@ jobs:
       with:
         name: Production Files (node${{ inputs.node-version }})
 
-    - name: Extract procution files
+    - name: Extract production files to isolated directory
       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
-      working-directory: ./apps/app
       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)
       if: ${{ matrix.browser == 'chromium' }}
@@ -240,13 +234,13 @@ jobs:
       env:
         DEBUG: pw:api
         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
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
     - name: Copy dotenv file for automatic installation
-      working-directory: ./apps/app
       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
       working-directory: ./apps/app
@@ -255,13 +249,13 @@ jobs:
       env:
         DEBUG: pw:api
         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
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
     - name: Copy dotenv file for automatic installation with allowing guest mode
-      working-directory: ./apps/app
       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)
       working-directory: ./apps/app
@@ -270,6 +264,7 @@ jobs:
       env:
         DEBUG: pw:api
         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
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 

+ 0 - 88
.github/workflows/reusable-app-reg-suit.yml

@@ -1,88 +0,0 @@
-name: Reusable VRT reporting workflow for production
-
-on:
-  workflow_call:
-    inputs:
-      node-version:
-        required: true
-        type: string
-      checkout-ref:
-        type: string
-        default: ${{ github.head_ref }}
-      skip-reg-suit:
-        type: boolean
-      cypress-report-artifact-name-pattern:
-        required: true
-        type: string
-    secrets:
-      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID:
-        required: true
-      AWS_ACCESS_KEY_ID:
-        required: true
-      AWS_SECRET_ACCESS_KEY:
-        required: true
-      SLACK_WEBHOOK_URL:
-        required: true
-    outputs:
-      EXPECTED_IMAGES_EXIST:
-        value: ${{ jobs.run-reg-suit.outputs.EXPECTED_IMAGES_EXIST }}
-
-
-jobs:
-
-  run-reg-suit:
-    # use secrets for "VRT" environment
-    # https://github.com/growilabs/growi/settings/environments/376165508/edit
-    environment: VRT
-
-    if: ${{ !inputs.skip-reg-suit }}
-
-    env:
-      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 }}
-
-    runs-on: ubuntu-latest
-
-    outputs:
-      EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
-
-    steps:
-    - uses: actions/checkout@v4
-      with:
-        ref: ${{ inputs.checkout-ref }}
-        fetch-depth: 0
-
-    - uses: pnpm/action-setup@v4
-
-    - uses: actions/setup-node@v4
-      with:
-        node-version: ${{ inputs.node-version }}
-        cache: 'pnpm'
-
-    - name: Install dependencies
-      run: |
-        pnpm install --frozen-lockfile
-
-    - name: Download screenshots taken by cypress
-      uses: actions/download-artifact@v4
-      with:
-        path: apps/app/test/cypress
-        pattern: ${{ inputs.cypress-report-artifact-name-pattern }}
-        merge-multiple: true
-
-    - name: Run reg-suit
-      working-directory: ./apps/app
-      run: |
-        pnpm run reg:run
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - run-reg-suit (${{ inputs.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 1 - 0
.gitignore

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

+ 466 - 0
.kiro/specs/auto-scroll/design.md

@@ -0,0 +1,466 @@
+# Design Document: auto-scroll
+
+## Overview
+
+**Purpose**: This feature provides a reusable hash-based auto-scroll mechanism that handles lazy-rendered content across GROWI's Markdown views. It compensates for layout shifts caused by asynchronous component rendering (e.g., Drawio diagrams, Mermaid charts, PlantUML images) by detecting in-progress renders and re-scrolling to the target.
+
+**Users**: End users navigating to hash-linked sections benefit from reliable scroll positioning. Developers integrating the hook into new views (PageView, SearchResultContent, future views) benefit from a standardized, configurable API.
+
+**Impact**: Refactors the existing `useHashAutoScroll` hook from a PageView-specific implementation into a shared, configurable hook. Renames and updates the rendering status attribute protocol for clarity and declarative usage. Also integrates hash-based auto-scroll into `SearchResultContent`, where the content pane has an independent scroll container.
+
+### Goals
+- Provide a single reusable hook for hash-based auto-scroll across all content views
+- Support customizable target resolution and scroll behavior per caller
+- Establish a clear, declarative rendering-status attribute protocol for async-rendering components
+- Maintain robust resource cleanup with timeout-based safety bounds
+- Integrate `SearchResultContent` as a second consumer with container-relative scroll strategy
+
+### Non-Goals
+- Adding `data-growi-is-content-rendering` to attachment-refs (Ref/Refs/RefImg/RefsImg/Gallery), or RichAttachment — these also cause layout shifts but require more complex integration; deferred to follow-up
+- Replacing SearchResultContent's keyword-highlight scroll with hash-based scroll (search pages have no URL hash)
+- Supporting non-browser environments (SSR) — this is a client-only hook
+
+## Architecture
+
+### Existing Architecture Analysis
+
+The current implementation lives in `apps/app/src/components/PageView/use-hash-auto-scroll.tsx`, tightly coupled to PageView via:
+- Hardcoded `document.getElementById(targetId)` for target resolution
+- Hardcoded `element.scrollIntoView()` for scroll execution
+- First parameter named `pageId` implying page-specific usage
+
+The rendering attribute `data-growi-rendering` is defined in `@growi/core` and consumed by:
+- `remark-drawio` (sets attribute on render start, removes on completion)
+- `use-hash-auto-scroll` (observes attribute presence via MutationObserver)
+
+### Architecture Pattern & Boundary Map
+
+> **Note**: This diagram reflects the final architecture after Task 8 module reorganization. See "Task 8 Design" section below for the migration details.
+
+```mermaid
+graph TB
+    subgraph growi_core[growi core]
+        CONST[Rendering Status Constants]
+    end
+
+    subgraph shared_util[src/client/util]
+        WATCH[watchRenderingAndReScroll]
+    end
+
+    subgraph page_view[src/components/PageView]
+        UHAS[useHashAutoScroll]
+        PV[PageView]
+    end
+
+    subgraph search[features/search/.../SearchPage]
+        UKR[useKeywordRescroll]
+        SRC[SearchResultContent]
+    end
+
+    subgraph renderers[Async Renderers]
+        DV[DrawioViewer]
+        MV[MermaidViewer]
+        PUV[PlantUmlViewer]
+        LSX[Lsx]
+    end
+
+    PV -->|calls| UHAS
+    UHAS -->|imports| WATCH
+    SRC -->|calls| UKR
+    UKR -->|imports| WATCH
+    WATCH -->|queries| CONST
+    DV -->|sets/toggles| CONST
+    MV -->|sets/toggles| CONST
+    PUV -->|sets/toggles| CONST
+    LSX -->|sets/toggles| CONST
+```
+
+**Architecture Integration**:
+- Selected pattern: Co-located hooks per consumer + shared utility function — idiomatic React, testable, minimal coupling
+- Domain boundaries: `watchRenderingAndReScroll` (shared pure function) in `src/client/util/`, consumer-specific hooks co-located with their components, constants in `@growi/core`, attribute lifecycle in each renderer package
+- Existing patterns preserved: MutationObserver + polling hybrid, timeout-based safety bounds
+- Steering compliance: Named exports, immutable patterns, co-located tests
+
+**Co-location rationale**: `watchRenderingAndReScroll` lives in `src/client/util/` (not `hooks/`) because it is a plain function, not a React hook — co-located with `smooth-scroll.ts` as both are DOM scroll utilities. `useHashAutoScroll` lives next to `PageView.tsx` because it is hash-navigation–specific (`window.location.hash`) and PageView is its only consumer. `useKeywordRescroll` lives next to `SearchResultContent.tsx` for the same reason. The old `src/client/hooks/use-content-auto-scroll/` shared directory was removed because the hook was never truly shared — only the underlying utility function was.
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Frontend | React 18 hooks (`useEffect`) | Hook lifecycle management | No new dependencies |
+| Browser API | MutationObserver, `setTimeout`, `requestAnimationFrame` | DOM observation, polling, and layout timing | Standard Web APIs |
+| Shared Constants | `@growi/core` | Rendering attribute definitions | Existing package |
+
+No new external dependencies are introduced.
+
+## System Flows
+
+### Auto-Scroll Lifecycle
+
+```mermaid
+sequenceDiagram
+    participant Caller as Content View (PageView)
+    participant Hook as useHashAutoScroll
+    participant DOM as DOM
+    participant Watch as watchRenderingAndReScroll
+
+    Caller->>Hook: useHashAutoScroll options
+    Hook->>Hook: Guard checks key, hash, container
+
+    alt Target exists in DOM
+        Hook->>DOM: resolveTarget
+        DOM-->>Hook: HTMLElement
+        Hook->>DOM: scrollTo target
+        Hook->>Watch: start rendering watch (always)
+    else Target not yet in DOM
+        Hook->>DOM: MutationObserver on container
+        DOM-->>Hook: target appears
+        Hook->>DOM: scrollTo target
+        Hook->>Watch: start rendering watch (always)
+    end
+
+    Note over Watch: MutationObserver detects rendering elements,<br/>including those that mount after the initial scroll
+
+    loop While rendering elements exist and within timeout
+        Watch->>DOM: query rendering-status attr
+        DOM-->>Watch: elements found
+        Watch-->>Watch: wait 5s
+        Watch->>DOM: scrollTo target
+    end
+
+    Note over Watch: Auto-cleanup after 10s timeout
+```
+
+Key decisions:
+- The two-phase approach (target observation → rendering watch) runs sequentially.
+- The rendering watch uses a non-resetting timer to prevent starvation from rapid DOM mutations.
+- **The rendering watch always starts after the initial scroll**, regardless of whether rendering elements exist at that moment. This is necessary because async renderers (Mermaid loaded via `dynamic()`, PlantUML images) may mount into the DOM *after* the hook's effect runs. The MutationObserver inside `watchRenderingAndReScroll` (`childList: true, subtree: true`) detects these late-mounting elements.
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1, 1.2 | Immediate scroll to hash target | useHashAutoScroll | UseHashAutoScrollOptions.resolveTarget | Auto-Scroll Lifecycle |
+| 1.3, 1.4, 1.5 | Guard conditions | useHashAutoScroll | UseHashAutoScrollOptions.key, contentContainerId | — |
+| 2.1, 2.2, 2.3 | Deferred scroll for lazy targets | useHashAutoScroll (target observer) | — | Auto-Scroll Lifecycle |
+| 3.1–3.6 | Re-scroll after rendering | watchRenderingAndReScroll | scrollToTarget callback | Auto-Scroll Lifecycle |
+| 4.1–4.7 | Rendering attribute protocol | Rendering Status Constants, DrawioViewer, MermaidViewer, PlantUmlViewer, Lsx | GROWI_IS_CONTENT_RENDERING_ATTR | — |
+| 4.8 | ResizeObserver re-render cycle | DrawioViewer | GROWI_IS_CONTENT_RENDERING_ATTR | — |
+| 5.1–5.5 | Page-type agnostic design | watchRenderingAndReScroll (shared), useHashAutoScroll (PageView), useKeywordRescroll (Search) | — | — |
+| 5.6, 5.7, 6.1–6.3 | Cleanup and safety | useHashAutoScroll, useKeywordRescroll, watchRenderingAndReScroll | cleanup functions | — |
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|--------------|--------|--------------|------------------|-----------|
+| useHashAutoScroll | src/components/PageView | Hash-based auto-scroll hook for PageView with configurable target resolution and scroll behavior | 1, 2, 5, 6 | watchRenderingAndReScroll (P0), Rendering Status Constants (P1) | Service |
+| useKeywordRescroll | features/search/.../SearchPage | Keyword-highlight scroll hook with rendering watch integration for SearchResultContent | 5, 6 | watchRenderingAndReScroll (P0), scrollWithinContainer (P0) | Service |
+| watchRenderingAndReScroll | src/client/util | Shared utility: polls for rendering-status attributes and re-scrolls until complete or timeout | 3, 6 | Rendering Status Constants (P0) | Service |
+| Rendering Status Constants | @growi/core | Shared attribute name, value, and selector constants | 4 | None | State |
+| DrawioViewer (modification) | remark-drawio | Declarative rendering-status attribute toggle | 4.3, 4.4, 4.8 | Rendering Status Constants (P0) | State |
+| MermaidViewer (modification) | features/mermaid | Add rendering-status attribute lifecycle to async SVG render | 4.3, 4.4, 4.7 | Rendering Status Constants (P0) | State |
+| PlantUmlViewer (new) | features/plantuml | Wrap PlantUML `<img>` to provide rendering-status attribute lifecycle | 4.3, 4.4, 4.7 | Rendering Status Constants (P0) | State |
+| Lsx (modification) | remark-lsx | Add rendering-status attribute lifecycle to async page list fetch | 4.3, 4.4, 4.7 | Rendering Status Constants (P0) | State |
+
+### Client Hooks
+
+#### useHashAutoScroll
+
+| Field | Detail |
+|-------|--------|
+| Intent | Hash-based auto-scroll hook for PageView that scrolls to a target element identified by URL hash, with support for lazy-rendered content and customizable scroll behavior |
+| Requirements | 1.1–1.5, 2.1–2.3, 5.1–5.7, 6.1–6.3 |
+
+**Responsibilities & Constraints**
+- Orchestrates the full hash-based auto-scroll lifecycle: guard → resolve target → scroll → watch rendering
+- Always delegates to `watchRenderingAndReScroll` after the initial scroll — does **not** skip the watch even when no rendering elements are present at scroll time, because async renderers may mount later
+- Co-located with `PageView.tsx` — this hook is hash-navigation–specific (`window.location.hash`)
+
+**Dependencies**
+- Outbound: `watchRenderingAndReScroll` from `~/client/util/watch-rendering-and-rescroll` (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+/** Configuration for the hash-based auto-scroll hook */
+interface UseHashAutoScrollOptions {
+  /**
+   * Unique key that triggers re-execution when changed.
+   * When null/undefined, all scroll processing is skipped.
+   */
+  key: string | undefined | null;
+
+  /** DOM id of the content container element to observe */
+  contentContainerId: string;
+
+  /**
+   * Optional function to resolve the scroll target element.
+   * Receives the decoded hash string (without '#').
+   * Defaults to: (hash) => document.getElementById(hash)
+   */
+  resolveTarget?: (decodedHash: string) => HTMLElement | null;
+
+  /**
+   * Optional function to scroll to the target element.
+   * Defaults to: (el) => el.scrollIntoView()
+   */
+  scrollTo?: (target: HTMLElement) => void;
+}
+
+/** Hook signature */
+function useHashAutoScroll(options: UseHashAutoScrollOptions): void;
+```
+
+- Preconditions: Called within a React component; browser environment with `window.location.hash` available
+- Postconditions: On unmount or key change, all observers and timers are cleaned up
+- Invariants: At most one target observer and one rendering watch active per hook instance
+
+**Implementation Notes**
+- File location: `apps/app/src/components/PageView/use-hash-auto-scroll.ts`
+- Test file: `apps/app/src/components/PageView/use-hash-auto-scroll.spec.tsx`
+- The `resolveTarget` and `scrollTo` callbacks should be wrapped in `useRef` to avoid re-triggering the effect when callback identity changes
+
+---
+
+#### useKeywordRescroll
+
+| Field | Detail |
+|-------|--------|
+| Intent | Keyword-highlight scroll hook for SearchResultContent that scrolls to the first `.highlighted-keyword` element and re-scrolls after async renderers settle |
+| Requirements | 5.1–5.7, 6.1–6.3 |
+
+**Responsibilities & Constraints**
+- MutationObserver on container for keyword highlight detection (debounced 500ms)
+- `watchRenderingAndReScroll` integration for async renderer layout shift compensation
+- Cleanup of both MO and rendering watch on key change or unmount
+- Co-located with `SearchResultContent.tsx`
+
+**Dependencies**
+- Outbound: `watchRenderingAndReScroll` from `~/client/util/watch-rendering-and-rescroll` (P0)
+- Outbound: `scrollWithinContainer` from `~/client/util/smooth-scroll` (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+interface UseKeywordRescrollOptions {
+  /** Ref to the scrollable container element */
+  scrollElementRef: RefObject<HTMLElement | null>;
+  /** Unique key that triggers re-execution (typically page._id) */
+  key: string;
+}
+
+function useKeywordRescroll(options: UseKeywordRescrollOptions): void;
+```
+
+- Preconditions: `scrollElementRef.current` is a mounted scroll container
+- Postconditions: On unmount or key change, MO disconnected, rendering watch cleaned up, debounce cancelled
+
+**Implementation Notes**
+- File location: `apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.ts`
+- Test file: `apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.spec.tsx`
+- Helper functions (`scrollToKeyword`, `scrollToTargetWithinContainer`) are defined in the hook file since only this hook uses them
+
+---
+
+#### watchRenderingAndReScroll
+
+| Field | Detail |
+|-------|--------|
+| Intent | Pure function (not a hook) that monitors rendering-status attributes and periodically re-scrolls until rendering completes or timeout. Shared utility consumed by both `useHashAutoScroll` and `useKeywordRescroll`. |
+| Requirements | 3.1–3.6, 6.1–6.3 |
+
+**Responsibilities & Constraints**
+- Sets up MutationObserver to detect rendering-status attribute changes **and** new rendering elements added to the DOM (childList + subtree)
+- Manages a non-resetting poll timer (5s interval)
+- Enforces a hard timeout (10s) to prevent unbounded observation
+- Returns a cleanup function
+
+**Dependencies**
+- External: `@growi/core` rendering status constants — attribute selector (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+/**
+ * Watches for elements with in-progress rendering status in the container.
+ * Periodically calls scrollToTarget while rendering elements remain.
+ * Returns a cleanup function that stops observation and clears timers.
+ */
+function watchRenderingAndReScroll(
+  contentContainer: HTMLElement,
+  scrollToTarget: () => boolean,
+): () => void;
+```
+
+- Preconditions: `contentContainer` is a mounted DOM element
+- Postconditions: Cleanup function disconnects observer, clears all timers
+- Invariants: At most one poll timer active at any time; stopped flag prevents post-cleanup execution
+
+**Implementation Notes**
+- File location: `apps/app/src/client/util/watch-rendering-and-rescroll.ts` (co-located with `smooth-scroll.ts`)
+- Test file: `apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx`
+- Add a `stopped` boolean flag checked inside timer callbacks to prevent race conditions between cleanup and queued timer execution
+- When `checkAndSchedule` detects that no rendering elements remain and a timer is currently active, cancel the active timer immediately — avoids a redundant re-scroll after rendering has already completed
+- The MutationObserver watches `childList`, `subtree`, and `attributes` (filtered to the rendering-status attribute) — the `childList` + `subtree` combination is what detects late-mounting async renderers
+- **Performance trade-off**: The function is always started regardless of whether rendering elements exist at call time. This means one MutationObserver + one 10s cleanup timeout run for every hash navigation, even on pages with no async renderers. The initial `checkAndSchedule()` call returns early if no rendering elements are present, so no poll timer is ever scheduled in that case — the only cost is the MO observation and the 10s cleanup timeout itself, which is acceptable.
+- **`querySelector` frequency**: The `checkAndSchedule` callback fires on every `childList` mutation (in addition to attribute changes). Each invocation runs `querySelector(GROWI_IS_CONTENT_RENDERING_SELECTOR)` on the container. This call is O(n) on the subtree but stops at the first match and is bounded by the 10s timeout, making it acceptable even for content-heavy pages.
+
+---
+
+### @growi/core Constants
+
+#### Rendering Status Constants
+
+| Field | Detail |
+|-------|--------|
+| Intent | Centralized constants for the rendering-status attribute name, values, and CSS selector |
+| Requirements | 4.1, 4.2, 4.6 |
+
+**Contracts**: State [x]
+
+##### State Management
+
+```typescript
+/** Attribute name applied to elements during async content rendering */
+const GROWI_IS_CONTENT_RENDERING_ATTR = 'data-growi-is-content-rendering' as const;
+
+/**
+ * CSS selector matching elements currently rendering.
+ * Matches only the "true" state, not completed ("false").
+ */
+const GROWI_IS_CONTENT_RENDERING_SELECTOR =
+  `[${GROWI_IS_CONTENT_RENDERING_ATTR}="true"]` as const;
+```
+
+- File location: `packages/core/src/consts/renderer.ts` (replaces existing constants)
+- Old constants (`GROWI_RENDERING_ATTR`, `GROWI_RENDERING_ATTR_SELECTOR`) are removed and replaced — no backward compatibility shim needed since all consumers are updated in the same change
+
+---
+
+### remark-drawio Modifications
+
+#### DrawioViewer (modification)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Adopt declarative attribute value toggling instead of imperative add/remove |
+| Requirements | 4.3, 4.4, 4.8 |
+
+**Implementation Notes**
+- Replace `removeAttribute(GROWI_RENDERING_ATTR)` calls with `setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false')`
+- Initial JSX: `{[GROWI_IS_CONTENT_RENDERING_ATTR]: 'true'}` (unchanged pattern, new constant name)
+- Update `SUPPORTED_ATTRIBUTES` in `remark-drawio.ts` to use new constant name
+- Update sanitize option to allow the new attribute name
+- **ResizeObserver re-render cycle** (req 4.8): In the ResizeObserver handler, call `drawioContainerRef.current?.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true')` before `renderDrawioWithDebounce()`. The existing inner MutationObserver (childList) completion path already sets the attribute back to `"false"` after each render.
+
+---
+
+### MermaidViewer Modification
+
+#### MermaidViewer (modification)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Add rendering-status attribute lifecycle to async `mermaid.render()` SVG rendering |
+| Requirements | 4.3, 4.4, 4.7 |
+
+**Implementation Notes**
+- Set `data-growi-is-content-rendering="true"` on the container element at initial render (via JSX spread before `mermaid.render()` is called)
+- After `mermaid.render()` completes and SVG is injected via `innerHTML`, delay the `"false"` signal using **`requestAnimationFrame`** so that the browser can compute the SVG layout before the auto-scroll system re-scrolls. Setting `"false"` synchronously after `innerHTML` assignment would signal completion before the browser has determined the element's final dimensions.
+- Set attribute to `"false"` immediately (without rAF) in the error/catch path, since no layout shift is expected on error
+- Cancel the pending rAF on effect cleanup to prevent state updates on unmounted components
+- File: `apps/app/src/features/mermaid/components/MermaidViewer.tsx`
+- The mermaid remark plugin sanitize options must be updated to include the new attribute name
+
+---
+
+### PlantUmlViewer (new component)
+
+#### PlantUmlViewer
+
+| Field | Detail |
+|-------|--------|
+| Intent | Wrap PlantUML image rendering in a component that signals rendering status, enabling the auto-scroll system to compensate for the layout shift when the external image loads |
+| Requirements | 4.3, 4.4, 4.7 |
+
+**Background**: PlantUML diagrams are rendered as `<img>` tags pointing to an external PlantUML server. The image load is asynchronous and causes a layout shift. The previous implementation had no `data-growi-is-content-rendering` support, so layout shifts from PlantUML images were never compensated.
+
+**Implementation Notes**
+- New component at `apps/app/src/features/plantuml/components/PlantUmlViewer.tsx`
+- Wraps `<img>` in a `<div>` container with `data-growi-is-content-rendering="true"` initially
+- Sets attribute to `"false"` via `onLoad` and `onError` handlers on the `<img>` element
+- The plantuml remark plugin (`plantuml.ts`) is updated to output a custom `<plantuml src="...">` HAST element instead of a plain `<img>`. This allows the renderer to map the `plantuml` element to the `PlantUmlViewer` React component.
+- `sanitizeOption` is exported from the plantuml service and merged in `renderer.tsx` (same pattern as drawio and mermaid)
+- `PlantUmlViewer` is registered as `components.plantuml` in all view option generators (`generateViewOptions`, `generateSimpleViewOptions`, `generatePreviewOptions`)
+
+---
+
+### remark-lsx Modification
+
+#### Lsx (modification)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Add rendering-status attribute lifecycle to async SWR page list fetching |
+| Requirements | 4.3, 4.4, 4.7 |
+
+**Implementation Notes**
+- Set `data-growi-is-content-rendering="true"` on the outermost container element while `isLoading === true` (SWR fetch in progress)
+- Set attribute to `"false"` when data arrives — whether success, error, or empty result
+- Use declarative attribute binding via the existing `isLoading` state (no imperative DOM manipulation needed)
+- File: `packages/remark-lsx/src/client/components/Lsx.tsx`
+- The lsx remark plugin sanitize options must be updated to include the new attribute name
+- `@growi/core` must be added as a dependency of `remark-lsx` (same pattern as `remark-drawio`)
+- **SWR cache hit behavior**: When SWR returns a cached result immediately (`isLoading=false` on first render), the attribute starts at `"false"` and no re-scroll is triggered. This is correct: a cached result means the list renders without a layout shift, so no compensation is needed. The re-scroll mechanism only activates when `isLoading` starts as `"true"` (no cache) and transitions to `"false"` after the fetch completes.
+
+---
+
+### SearchResultContent Integration
+
+#### SearchResultContent (modification)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Integrate rendering-watch into SearchResultContent's keyword scroll so that layout shifts from async renderers are compensated |
+| Requirements | 5.1, 5.4, 5.5, 6.1 |
+
+**Background**: `SearchResultContent` renders page content inside a div with `overflow-y-scroll` (`#search-result-content-body-container`). The keyword-highlight scroll mechanism was originally inlined as a `useEffect` with no dependency array and no cleanup.
+
+**Post-Implementation Correction**: The initial design (tasks 6.1–6.3) attempted to integrate `useContentAutoScroll` (hash-based) into SearchResultContent. This was architecturally incorrect — search pages use `/search?q=foo` with no URL hash, so the hash-driven hook would never activate. See `research.md` "Post-Implementation Finding" for details.
+
+**Final Architecture**: The keyword scroll effect was extracted into a dedicated `useKeywordRescroll` hook (co-located with SearchResultContent), which directly integrates `watchRenderingAndReScroll` for rendering compensation. No hash-based scroll is used in SearchResultContent.
+
+**Hook Call Site**
+
+```typescript
+useKeywordRescroll({ scrollElementRef, key: page._id });
+```
+
+- `scrollElementRef` is the existing React ref pointing to the scroll container
+- `key: page._id` triggers re-execution when the selected page changes
+- The hook internally handles MutationObserver setup, debounced keyword scroll, rendering watch, and full cleanup
+
+**File**: `apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx`
+
+---
+
+## Error Handling
+
+### Error Strategy
+
+This feature operates entirely in the browser DOM layer with no server interaction. Errors are limited to DOM state mismatches.
+
+### Error Categories and Responses
+
+**Target Not Found** (2.3): If the hash target never appears within 10s, the observer disconnects silently. No error is surfaced to the user — this matches browser-native behavior for invalid hash links.
+
+**Container Not Found** (1.5): If the container element ID does not resolve, the hook returns immediately with no side effects.
+
+**Rendering Watch Timeout** (3.6): After 10s, all observers and timers are cleaned up regardless of remaining rendering elements. This prevents resource leaks from components that fail to signal completion.
+

+ 91 - 0
.kiro/specs/auto-scroll/requirements.md

@@ -0,0 +1,91 @@
+# Requirements Document
+
+## Introduction
+
+This specification defines the behavior of the **hash-based auto-scroll** mechanism used across GROWI's content pages. When a user navigates to a URL containing a fragment hash (e.g., `#section-title`), the system scrolls to the corresponding element in the rendered content. Because GROWI pages contain lazily-rendered elements (Drawio diagrams, Mermaid charts, etc.) that cause layout shifts after initial paint, the system must detect in-progress renders and re-scroll to compensate.
+
+This hook is designed to be **page-type agnostic** — it must work in any view that renders Markdown content with a hash-addressable container (PageView, search result previews, etc.).
+
+## Review Feedback (from yuki-takei, PR #10853)
+
+The following reviewer feedback is incorporated into these requirements:
+
+1. **Rendering attribute value**: Use declarative `true`/`false` toggling instead of `setAttribute`/`removeAttribute` — the attribute should always be present with a boolean-like value, not added/removed.
+2. **Attribute naming**: The attribute name should more clearly convey "rendering in progress" status. The name will be finalized in the design phase but must be more descriptive than `data-growi-rendering`.
+3. **Hook generalization**: Move to `src/client/hooks/` for shared use; accept a target-resolving closure instead of hardcoded `getElementById`; support customizable scroll behavior (e.g., `scrollIntoView` for PageView vs. a different method for SearchResultContent); rename the hook accordingly.
+
+## Requirements
+
+### Requirement 1: Immediate Scroll to Hash Target
+
+**Objective:** As a user, I want to be scrolled to the section referenced by the URL hash when I open a page, so that I can directly access the content I was linked to.
+
+#### Acceptance Criteria
+
+1. When the page loads with a URL hash and the target element already exists in the DOM, the hook shall scroll the target element into view immediately.
+2. When the page loads with a URL hash containing encoded characters (e.g., `%E6%97%A5%E6%9C%AC%E8%AA%9E`), the hook shall decode the hash and locate the corresponding element by its `id` attribute.
+3. If the key parameter is null or undefined, the hook shall skip all scroll processing.
+4. If the URL hash is empty, the hook shall skip all scroll processing.
+5. If the content container element is not found in the DOM, the hook shall skip all scroll processing.
+
+### Requirement 2: Deferred Scroll for Lazy-Rendered Targets
+
+**Objective:** As a user, I want the page to scroll to my target section even when the content is rendered after initial page load, so that dynamically rendered headings are still reachable via URL hash.
+
+#### Acceptance Criteria
+
+1. When the page loads with a URL hash and the target element does not yet exist in the DOM, the hook shall observe the content container for DOM mutations until the target appears.
+2. When the target element appears in the DOM during observation, the hook shall immediately scroll it into view.
+3. If the target element does not appear within the watch timeout period (default: 10 seconds), the hook shall stop observing and give up without error.
+
+### Requirement 3: Re-Scroll After Rendering Completion
+
+**Objective:** As a user, I want the view to re-adjust after lazy-rendered content (e.g., Drawio diagrams) finishes rendering, so that layout shifts do not push my target section out of view.
+
+#### Acceptance Criteria
+
+1. When an initial scroll completes and elements whose rendering-status attribute indicates "in progress" exist in the content container, the hook shall schedule a re-scroll after a poll interval (default: 5 seconds).
+2. While elements with in-progress rendering status remain in the container after a re-scroll, the hook shall repeat the poll-and-re-scroll cycle.
+3. When no elements with in-progress rendering status remain after a re-scroll check, the hook shall stop re-scrolling.
+4. When new elements with in-progress rendering status appear in the container (detected via MutationObserver), the hook shall schedule a re-scroll if one is not already pending.
+5. The hook shall not reset a running poll timer when additional DOM mutations occur — only schedule a new timer when no timer is active.
+6. The rendering watch shall automatically terminate after the watch timeout period (default: 10 seconds) regardless of remaining rendering elements.
+
+### Requirement 4: Rendering Status Attribute Protocol
+
+**Objective:** As a developer, I want a standardized attribute for components to signal their rendering status declaratively, so that the auto-scroll system can detect layout-shifting content generically.
+
+#### Acceptance Criteria
+
+1. The attribute name and its CSS selector for the "in progress" state shall be defined as shared constants in `@growi/core`.
+2. The attribute name shall clearly convey that rendering is in progress (e.g., more descriptive than a generic `data-growi-rendering`). The final name will be determined in the design phase.
+3. When a component begins rendering content that will change its dimensions (e.g., Drawio diagram initialization), the component shall set the attribute value to indicate "in progress" (e.g., `"true"`).
+4. When the component finishes rendering or encounters an error, the component shall set the attribute value to indicate "completed" (e.g., `"false"`) rather than removing the attribute entirely — the attribute lifecycle shall be declarative (value toggle), not imperative (add/remove).
+5. The attribute shall be included in the component's HTML sanitization allowlist so that it survives remark/rehype processing.
+6. The CSS selector used by the auto-scroll system shall match only the "in progress" state (e.g., `[attr="true"]`), not the completed state.
+7. The following async-rendering components shall adopt the attribute protocol in this scope: DrawioViewer, MermaidViewer, PlantUmlViewer (new wrapper component), and lsx (Lsx). Other async renderers (attachment-refs, RichAttachment) are deferred to follow-up work.
+8. When a component triggers a secondary re-render that will cause a layout shift (e.g., via ResizeObserver detecting container size changes after initial render), the component shall reset the attribute value to `"true"` before the re-render begins and allow the existing completion path to set it back to `"false"` when done. This ensures the auto-scroll system tracks all layout-shifting render cycles, not only the initial one.
+
+### Requirement 5: Page-Type Agnostic Design
+
+**Objective:** As a developer, I want the auto-scroll hook to be reusable across different page types (wiki pages, search results, etc.), so that hash-based scrolling behaves consistently throughout the application.
+
+#### Acceptance Criteria
+
+1. The hook shall accept a generic key parameter (not limited to page IDs) and a content container element ID as its inputs.
+2. The hook shall accept an optional target-resolving function (closure) that returns the target `HTMLElement | null`. When not provided, the hook shall default to resolving the target via `document.getElementById` using the decoded hash.
+3. The hook shall accept an optional scroll function that defines how to scroll to the target element. When not provided, the hook shall default to `element.scrollIntoView()`. This allows callers (e.g., SearchResultContent) to supply a custom scroll strategy.
+4. The hook shall not import or depend on any page-specific state (Jotai atoms, SWR hooks, or page models).
+5. The shared rendering-watch utility (`watchRenderingAndReScroll`) shall be located in a shared directory (e.g., `src/client/util/`). Each consumer-specific hook shall be co-located with its consumer component and named to reflect its purpose (e.g., hash-based scroll for PageView, keyword-based re-scroll for SearchResultContent).
+6. When the key parameter changes, the hook shall clean up any active observers and timers from the previous run and re-execute the scroll logic.
+7. When the component using the hook unmounts, the hook shall clean up all MutationObservers, timers, and rendering watch resources.
+
+### Requirement 6: Resource Cleanup and Safety
+
+**Objective:** As a developer, I want the hook to be safe against memory leaks and runaway timers, so that it can be used in any component lifecycle without side effects.
+
+#### Acceptance Criteria
+
+1. When the hook's effect cleanup runs, the hook shall disconnect all MutationObservers, clear all pending timers, and invoke any rendering watch cleanup functions.
+2. The hook shall enforce a maximum watch duration (default: 10 seconds) for both target observation and rendering watch, preventing indefinite resource consumption.
+3. While multiple elements with the rendering-status attribute (in-progress state) exist simultaneously, the hook shall execute only one re-scroll (not one per element).

+ 246 - 0
.kiro/specs/auto-scroll/research.md

@@ -0,0 +1,246 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `auto-scroll`
+- **Discovery Scope**: Extension (refactoring existing hook for reusability)
+- **Key Findings**:
+  - `src/client/hooks/` does not exist; hooks are collocated with features — a new shared hooks directory is needed
+  - SearchResultContent has independent scroll-to-highlighted-keyword logic using MutationObserver; coordination needed
+  - MermaidViewer does not implement the rendering attribute protocol; DrawioViewer is the only adopter
+
+## Research Log
+
+### Hook Location and Existing Patterns
+- **Context**: Requirement 5.5 specifies placing the hook in `src/client/hooks/`
+- **Findings**:
+  - `apps/app/src/client/hooks/` does not exist
+  - Existing hooks are collocated: `features/page-tree/hooks/`, `features/openai/client/components/.../hooks/`
+  - No precedent for a top-level shared hooks directory in `src/client/`
+- **Implications**: Creating `src/client/hooks/` establishes a new pattern for cross-feature hooks
+
+### SearchResultContent Scroll Behavior
+- **Context**: Requirement 5 mandates reusability for search result pages
+- **Sources**: `apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx`
+- **Findings**:
+  - Container ID: `search-result-content-body-container`
+  - Container has `overflow-y-scroll` — is the scroll unit, not the viewport
+  - Uses MutationObserver to find `.highlighted-keyword` elements and scroll to the first one using `scrollWithinContainer`
+  - Debounced at 500ms; `SCROLL_OFFSET_TOP = 30`
+  - Does NOT use URL hash — scrolls to highlighted search terms
+  - `useEffect` has no dependency array (fires on every render); no cleanup (intentional per inline comment)
+- **Implications (updated)**:
+  - `scrollIntoView()` default is inappropriate; custom `scrollTo` using `scrollWithinContainer` is required
+  - When `window.location.hash` is non-empty, the keyword scroll overrides hash scroll after 500ms debounce — must be suppressed via early return guard
+  - The `resolveTarget` default (`document.getElementById`) works correctly; heading `id` attributes are set by the remark pipeline
+
+### DrawioViewer Rendering Attribute Pattern
+- **Context**: Requirement 4.4 mandates declarative true/false toggling
+- **Sources**: `packages/remark-drawio/src/components/DrawioViewer.tsx`
+- **Findings**:
+  - Initial render: `{[GROWI_RENDERING_ATTR]: 'true'}` in JSX spread (line 188)
+  - On error: `removeAttribute(GROWI_RENDERING_ATTR)` (line 131)
+  - On complete: `removeAttribute(GROWI_RENDERING_ATTR)` (line 148)
+  - This is imperative add/remove, not declarative value toggle
+- **Implications**: Needs refactoring to `setAttribute(attr, 'false')` on completion/error instead of `removeAttribute`
+
+### MermaidViewer Status
+- **Context**: Could benefit from rendering attribute protocol
+- **Sources**: `apps/app/src/features/mermaid/components/MermaidViewer.tsx`
+- **Findings**:
+  - Does NOT use `GROWI_RENDERING_ATTR`
+  - Uses `mermaid.render()` async with direct `innerHTML` assignment
+  - Mermaid sanitize options only allow `value` attribute
+- **Implications**: Adding Mermaid support is a separate task, not in scope for this spec, but the design should be compatible
+
+### Rendering Attribute Naming
+- **Context**: Reviewer feedback requests a more descriptive name
+- **Findings**:
+  - Current: `data-growi-rendering` — ambiguous (rendering what?)
+  - Candidates considered:
+    - `data-growi-is-rendering-in-progress` — explicit but verbose
+    - `data-growi-rendering-status` — implies multiple states
+    - `data-growi-content-rendering` — slightly more specific
+  - With declarative true/false, a boolean-style name like `data-growi-is-content-rendering` works well
+- **Implications**: Selected `data-growi-is-content-rendering` — clearly a boolean predicate, reads naturally as `is-content-rendering="true"/"false"`
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Custom hook with options object | Single hook with configurable resolveTarget and scrollTo callbacks | Clean API, single import, testable | Options object may grow over time | Selected approach |
+| Separate hooks per page type | usePageHashScroll, useSearchScroll | Type-specific optimization | Duplicated watch/cleanup logic | Rejected — violates DRY |
+| HOC wrapper | Higher-order component wrapping scroll behavior | Framework-agnostic | Harder to compose, less idiomatic React | Rejected — hooks are idiomatic |
+
+## Design Decisions
+
+### Decision: Hook API Shape
+- **Context**: Hook must support PageView (hash-based) and SearchResultContent (keyword-based) with different scroll strategies
+- **Alternatives Considered**:
+  1. Positional parameters — `useAutoScroll(key, containerId, resolveTarget?, scrollFn?)`
+  2. Options object — `useAutoScroll(options)`
+- **Selected Approach**: Options object with required `key` and `contentContainerId`, optional `resolveTarget` and `scrollTo`
+- **Rationale**: Options object is extensible without breaking existing call sites and self-documents parameter intent
+- **Trade-offs**: Slightly more verbose at call site; mitigated by clear defaults
+
+### Decision: Attribute Name
+- **Context**: Reviewer feedback: name should clearly convey "rendering in progress"
+- **Selected Approach**: `data-growi-is-content-rendering` with values `"true"` / `"false"`
+- **Rationale**: Boolean predicate naming (`is-*`) is natural for a two-state attribute; `content-rendering` disambiguates from other rendering concepts
+- **Follow-up**: Update `@growi/core` constant and all consumers
+
+### Decision: CSS Selector for In-Progress State
+- **Context**: Requirement 4.6 — selector must match only in-progress state
+- **Selected Approach**: `[data-growi-is-content-rendering="true"]` instead of bare attribute selector
+- **Rationale**: With declarative true/false toggling, bare `[attr]` matches both states; value selector is required
+
+## Risks & Mitigations
+- **Risk**: SearchResultContent's existing keyword-highlight scroll may conflict with hash-based scroll — **Mitigation**: Guard the keyword-scroll `useEffect` with `if (window.location.hash.length > 0) return;` so hash scroll takes priority when a hash is present; keyword scroll proceeds unchanged otherwise
+- **Risk**: `scrollIntoView()` default scrolls the viewport when SearchResultContent's container has `overflow-y-scroll` — **Mitigation**: Provide a custom `scrollTo` closure using `scrollWithinContainer` with offset from the container's bounding rect
+- **Risk**: Renaming the attribute requires coordinated changes across `@growi/core`, `remark-drawio`, and consuming apps — **Mitigation**: Constants are centralized; single constant rename propagates via imports
+- **Risk**: MutationObserver on `subtree: true` may be expensive on large pages — **Mitigation**: Retained 10s maximum watch timeout from current implementation
+
+## Post-Implementation Finding: SearchResultContent Integration Misalignment
+
+**Discovered after task 6 implementation** during code review conversation.
+
+### Problem
+
+The task 6 implementation added `useContentAutoScroll` to `SearchResultContent`, but this was architecturally incorrect. `useContentAutoScroll` is URL-hash–driven (`if (hash.length === 0) return`) and will never activate in the search results context — the search page URL (`/search?q=foo`) carries no fragment identifier.
+
+### Actual Requirement
+
+The real requirement for SearchResultContent is:
+1. **Keyword scroll** (already working): scroll to the first `.highlighted-keyword` element when content loads, via MutationObserver + 500ms debounce
+2. **Re-scroll after rendering** (missing): when drawio / mermaid diagrams render asynchronously after the initial keyword scroll, the layout shifts and the keyword moves out of view — `watchRenderingAndReScroll` should re-scroll to the keyword once rendering settles
+
+### Current Code State (as of this writing)
+
+`apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx` contains:
+- `useContentAutoScroll(...)` call — **should be removed**
+- keyword scroll `useEffect` with hash guard (`if (window.location.hash.length > 0) return`) — the guard may also be removable depending on how the hook is refactored
+- `scrollToTargetWithinContainer` local helper (shared distance calculation) — **keep**
+
+### Proposed Refactoring Direction
+
+Two-phase refactor, designed for the next session:
+
+**Phase 1 — Immediate fix (SearchResultContent)**
+
+Wire `watchRenderingAndReScroll` directly into the keyword scroll `useEffect`:
+
+```typescript
+useEffect(() => {
+  const scrollElement = scrollElementRef.current;
+  if (scrollElement == null) return;
+
+  const scrollToKeyword = (): boolean => {
+    const toElem = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
+    if (toElem == null) return false;
+    scrollToTargetWithinContainer(toElem, scrollElement);
+    return true;
+  };
+
+  // MutationObserver for incremental content loading (debounced)
+  const observer = new MutationObserver(() => {
+    scrollToFirstHighlightedKeywordDebounced(scrollElement);
+  });
+  observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
+
+  // Rendering watch: re-scroll after drawio/mermaid layout shifts
+  const cleanupWatch = watchRenderingAndReScroll(scrollElement, scrollToKeyword);
+  return cleanupWatch;
+}, [page._id]);
+```
+
+Remove the `useContentAutoScroll` import and call entirely.
+
+**Phase 2 — Architecture improvement (shared hook)**
+
+Reorganize the relationship between `useContentAutoScroll` and `watchRenderingAndReScroll`:
+
+- `watchRenderingAndReScroll` (pure function) is the core shared primitive — **promote it to a named export** so callers other than `useContentAutoScroll` can use it directly
+- Consider introducing a thin React wrapper hook `useRenderingRescroll(scrollToTarget, deps)` that manages the `useEffect` lifecycle for `watchRenderingAndReScroll`, making it composable
+- `useContentAutoScroll` becomes the **hash-navigation–specific** hook: hash guard → target resolution → initial scroll → delegates to `useRenderingRescroll`
+- `SearchResultContent` keyword scroll becomes: MO-debounce → initial scroll → delegates to `useRenderingRescroll`
+- PageView-specific logic (default `scrollIntoView`, `getElementById` resolver) stays in PageView or in `useContentAutoScroll`
+
+Resulting dependency graph:
+
+```
+useContentAutoScroll  ─┐
+                        ├── useRenderingRescroll ── watchRenderingAndReScroll
+SearchResultContent   ─┘
+```
+
+### Key Questions for Next Session Design
+
+1. Should `useRenderingRescroll` be a hook (managing `useEffect` internally) or should callers be responsible for calling it inside their own effect? A hook is more ergonomic; a plain function is more flexible.
+2. The current keyword-scroll `useEffect` has no dependency array (fires every render) and no cleanup — intentional per inline comment. Adding `[page._id]` deps and a cleanup changes this behavior. Is that safe?
+3. Should the hash guard on the keyword-scroll `useEffect` be removed once `useContentAutoScroll` is also removed from `SearchResultContent`?
+
+## Task 8 Analysis: useRenderingRescroll Hook Extraction
+
+### Investigation (2026-04-06)
+
+**Objective**: Determine whether extracting a shared `useRenderingRescroll` hook is architecturally beneficial after tasks 1–7 completion.
+
+**Method**: Code review of current implementations — `useContentAutoScroll` (108 lines), `watchRenderingAndReScroll` (85 lines), `SearchResultContent` keyword-scroll effect (lines 133–161).
+
+### Findings
+
+**1. Hook extraction is architecturally infeasible for `useContentAutoScroll`**
+
+`useContentAutoScroll` calls `watchRenderingAndReScroll` conditionally inside its `useEffect`:
+- On the immediate path: only after `scrollToTarget()` returns true (line 77)
+- On the deferred path: only after the MutationObserver detects the target element (line 91)
+
+React hooks cannot be called conditionally or inside callbacks. A `useRenderingRescroll` hook would need an "enabled" flag pattern, adding complexity without simplification.
+
+**2. Co-located cleanup in SearchResultContent prevents separation**
+
+The keyword-scroll `useEffect` in `SearchResultContent` (lines 135–160) combines:
+- MutationObserver for keyword highlight detection
+- `watchRenderingAndReScroll` for async renderer compensation
+- Single cleanup return that handles both
+
+Extracting the watch into a separate hook would split cleanup across two effects, making the lifecycle harder to reason about.
+
+**3. All three design questions from the original research are resolved**
+
+| Question | Resolution | How |
+|----------|------------|-----|
+| Hook vs. function | Plain function | Conditional call inside effect prevents hook usage |
+| `[page._id]` deps + cleanup safe? | Yes, safe | Implemented in task 7.1, working correctly |
+| Hash guard removal | Already done | Removed in task 7.1 alongside `useContentAutoScroll` removal |
+
+**4. Current architecture is already optimal**
+
+`watchRenderingAndReScroll` as a plain function returning a cleanup closure is the correct abstraction level:
+- Composable into any `useEffect` (conditional or unconditional)
+- No React runtime coupling (testable without `renderHook`)
+- Clean dependency graph with two independent consumers
+
+### Initial Recommendation (superseded)
+
+Initially recommended closing Task 8 without code changes. However, after discussion the scope was revised from "hook extraction" to "module reorganization" — see below.
+
+### Revised Direction: Module Reorganization (2026-04-06)
+
+**Context**: The user observed that while a shared `useRenderingRescroll` hook adds no value (confirmed by analysis above), the current file layout is inconsistent:
+
+1. `useContentAutoScroll` lives in `src/client/hooks/` (shared) but is PageView-specific (hash-dependent)
+2. `watchRenderingAndReScroll` lives next to that hook as if internal, but is the actual shared primitive
+3. SearchResultContent's scroll logic is inlined rather than extracted
+
+**Revised approach**:
+- Move `watchRenderingAndReScroll` to `src/client/util/` — co-located with `smooth-scroll.ts` (both are DOM scroll utilities)
+- Rename `useContentAutoScroll` → `useHashAutoScroll` and move next to `PageView.tsx`
+- Extract keyword-scroll effect from `SearchResultContent` into co-located `useKeywordRescroll` hook
+- Delete `src/client/hooks/use-content-auto-scroll/` directory
+
+**Rationale**: Module co-location over shared directory. Each hook lives next to its only consumer. Only the truly shared primitive (`watchRenderingAndReScroll`) stays in a shared directory — and it moves from `hooks/` to `util/` since it's a plain function, not a hook.
+
+## References
+- [MutationObserver API](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) — core browser API used for DOM observation
+- [Element.scrollIntoView()](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) — default scroll behavior
+- PR #10853 reviewer feedback from yuki-takei — driving force for this refactoring

+ 24 - 0
.kiro/specs/auto-scroll/spec.json

@@ -0,0 +1,24 @@
+{
+  "feature_name": "auto-scroll",
+  "created_at": "2026-04-02T00:00:00.000Z",
+  "updated_at": "2026-04-07T12:00:00.000Z",
+  "cleanup_completed": true,
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true,
+      "notes": "All tasks (1–8) complete. Design updated to reflect final architecture after module reorganization."
+    }
+  },
+  "ready_for_implementation": true
+}

+ 154 - 0
.kiro/specs/auto-scroll/tasks.md

@@ -0,0 +1,154 @@
+# Implementation Plan
+
+- [x] 1. Update rendering status constants in @growi/core
+  - Rename the attribute constant from the current name to `data-growi-is-content-rendering` to clearly convey boolean rendering-in-progress semantics
+  - Update the CSS selector constant to match only the in-progress state (`="true"`) rather than bare attribute presence
+  - Remove the old constants — no backward-compatibility aliases since all consumers are updated in the same change
+  - _Requirements: 4.1, 4.2, 4.6_
+
+- [x] 2. Update remark-drawio for declarative rendering attribute protocol
+- [x] 2.1 (P) Adopt declarative value toggling in DrawioViewer component
+  - Change rendering-complete and error paths to set the attribute value to `"false"` instead of removing the attribute entirely
+  - Update the initial JSX spread to use the renamed constant while keeping `"true"` as the initial value
+  - Verify that the wrapper component (DrawioViewerWithEditButton) continues to function without changes
+  - In the ResizeObserver handler, set `attr="true"` before `renderDrawioWithDebounce()` to signal re-render cycles to the auto-scroll system (req 4.8)
+  - _Requirements: 4.3, 4.4, 4.8_
+- [x] 2.2 (P) Update remark-drawio plugin sanitization and node rewriting
+  - Replace the old constant in the supported-attributes array with the new constant name
+  - Update node rewriting to set the new attribute name with `"true"` value on drawio nodes
+  - Confirm the sanitize export still passes the new attribute through HTML sanitization
+  - _Requirements: 4.5_
+
+- [x] 3. Add rendering attribute to MermaidViewer and Lsx
+- [x] 3.1 (P) Add rendering-status attribute lifecycle to MermaidViewer
+  - Set the rendering-status attribute to `"true"` on the container element at initial render before the async SVG render starts
+  - Set the attribute to `"false"` after `mermaid.render()` completes and the SVG is injected into the DOM
+  - Set the attribute to `"false"` in the error/catch path as well
+  - Update the mermaid remark plugin sanitize options to include the new attribute name in the allowlist
+  - _Requirements: 4.3, 4.4, 4.5, 4.7_
+- [x] 3.2 (P) Add rendering-status attribute lifecycle to Lsx component
+  - Set the rendering-status attribute to `"true"` on the outermost container while the SWR page list fetch is loading
+  - Set the attribute to `"false"` when data arrives — success, error, or empty result — using declarative binding from the existing `isLoading` state
+  - Update the lsx remark plugin sanitize options to include the new attribute name in the allowlist
+  - Add `@growi/core` as a dependency of `remark-lsx` (same pattern as `remark-drawio`)
+  - _Requirements: 4.3, 4.4, 4.5, 4.7_
+
+- [x] 4. Implement shared auto-scroll hook
+- [x] 4.1 Implement rendering watch function with safety improvements
+  - Create the `watchRenderingAndReScroll` function in the new shared hooks directory using the updated rendering-status selector
+  - Add a `stopped` boolean flag checked inside timer callbacks to prevent execution after cleanup (race condition fix from PR review)
+  - Maintain the existing non-resetting timer pattern: skip scheduling when a timer is already active
+  - When `checkAndSchedule` detects no rendering elements remain while a timer is still active, cancel the active timer immediately to avoid a redundant re-scroll after rendering has completed
+  - Enforce the 10-second hard timeout that cleans up observer and all timers regardless of remaining rendering elements
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 6.1, 6.2, 6.3_
+  - _Contracts: watchRenderingAndReScroll service interface_
+- [x] 4.2 Implement useContentAutoScroll hook with options object API
+  - Create the hook accepting an options object with `key`, `contentContainerId`, optional `resolveTarget`, and optional `scrollTo`
+  - Implement guard logic: skip processing when key is null/undefined, hash is empty, or container element not found
+  - Implement immediate scroll path: resolve target via provided closure (default: `getElementById`), scroll via provided function (default: `scrollIntoView`), then check for rendering elements before delegating to rendering watch — skip watch entirely if no rendering elements exist
+  - Implement deferred scroll path: MutationObserver on container until target appears, then scroll and conditionally delegate to rendering watch (same check), with 10-second timeout
+  - Store `resolveTarget` and `scrollTo` callbacks in refs to avoid re-triggering the effect on callback identity changes
+  - Wire cleanup to disconnect all observers, clear all timers, and invoke rendering watch cleanup
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 5.1, 5.2, 5.3, 5.4, 5.6, 5.7, 6.1, 6.2_
+  - _Contracts: UseContentAutoScrollOptions, useContentAutoScroll service interface_
+- [x] 4.3 (P) Write tests for watchRenderingAndReScroll
+  - Test that no timer is scheduled when no rendering elements exist
+  - Test that a re-scroll fires after the 5-second poll interval when rendering elements are present
+  - Test that the timer is not reset by intermediate DOM mutations
+  - Test that late-appearing rendering elements are detected by the observer and trigger a timer
+  - Test that only one re-scroll executes per cycle even with multiple rendering elements
+  - Test that the 10-second watch timeout cleans up all resources
+  - Test that the stopped flag prevents timer callbacks from executing after cleanup
+  - Test that an active timer is cancelled when rendering elements are removed before the timer fires
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 6.1, 6.2, 6.3_
+- [x] 4.4 (P) Write tests for useContentAutoScroll
+  - Test guard conditions: no-op when key is null, hash is empty, or container not found
+  - Test immediate scroll when target already exists in DOM
+  - Test deferred scroll when target appears after initial render via MutationObserver
+  - Test that encoded hash values are decoded correctly before target resolution
+  - Test that a custom `resolveTarget` closure is called instead of the default
+  - Test that a custom `scrollTo` function is called instead of the default
+  - Test cleanup on key change: observers and timers from previous run are released
+  - Test cleanup on unmount: all resources are released
+  - Test rendering watch integration: re-scroll fires when rendering elements exist after initial scroll
+  - Test that rendering watch is skipped when no rendering elements exist after initial scroll
+  - Test 10-second timeout for target observation when target never appears
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 5.1, 5.2, 5.3, 5.6, 5.7, 6.1, 6.2_
+
+- [x] 5. Integrate hook into PageView and remove old implementation
+  - Replace the import of the old hook with the new shared hook in PageView
+  - Update the call site to use the options object API with `key: currentPageId` and `contentContainerId` — no custom `resolveTarget` or `scrollTo` needed (defaults match PageView's behavior)
+  - Delete the old hook file and its test file from the PageView directory
+  - Verify that PageView auto-scroll behavior is preserved with manual testing or existing test coverage
+  - _Requirements: 5.1, 5.4, 5.5_
+
+- [x] 6. Integrate useContentAutoScroll into SearchResultContent
+- [x] 6.1 (P) Add hash-based auto-scroll with container-relative scroll strategy
+  - Call `useContentAutoScroll` with `key: page._id` and `contentContainerId: 'search-result-content-body-container'`
+  - Provide a custom `scrollTo` closure that calculates the target element's offset relative to the container's bounding rect and calls `scrollWithinContainer` with the same `SCROLL_OFFSET_TOP` constant already used for keyword scroll
+  - Capture the container via the existing `scrollElementRef` in the closure to avoid a redundant `getElementById` lookup
+  - Do not provide a custom `resolveTarget` — heading elements have `id` attributes set by the remark pipeline, so the default `getElementById` resolver works correctly
+  - _Requirements: 5.1, 5.2, 5.3, 5.5_
+
+- [x] 6.2 (P) Suppress keyword-highlight scroll when a URL hash is present
+  - Add an early return guard at the top of the existing keyword-scroll `useEffect`: if `window.location.hash` is non-empty, return immediately so hash-based scroll is not overridden by the debounced keyword scroll
+  - Preserve the existing keyword-scroll behavior fully when no hash is present — the MutationObserver, debounce interval, and `scrollWithinContainer` call remain unchanged
+  - _Requirements: 5.1, 5.5_
+
+- [x] 6.3 Write tests for SearchResultContent auto-scroll integration
+  - Test that `useContentAutoScroll` is called with the correct `key` and `contentContainerId` when the component mounts
+  - Test that the custom `scrollTo` scrolls within the container (not the viewport) by verifying `scrollWithinContainer` is called with the correct distance
+  - Test that the keyword-scroll `useEffect` skips observation when `window.location.hash` is non-empty
+  - Test that the keyword-scroll `useEffect` sets up the MutationObserver normally when no hash is present
+  - _Requirements: 5.1, 5.2, 5.3, 5.5_
+
+---
+
+## Phase 2: Module Reorganization
+
+> **Context**: Tasks 1–7 delivered all functional requirements. Task 8 reorganizes modules for co-location: each hook moves next to its consumer, and the shared rendering watch utility moves to `src/client/util/`. No behavior changes — pure structural improvement.
+
+- [x] 7. Fix SearchResultContent: replace `useContentAutoScroll` with `watchRenderingAndReScroll`
+- [x] 7.1 Wire `watchRenderingAndReScroll` into keyword-scroll effect
+  - Remove `useContentAutoScroll` import and call from `SearchResultContent.tsx`
+  - Import `watchRenderingAndReScroll` (already exported from `watch-rendering-and-rescroll.ts`)
+  - Inside the keyword-scroll `useEffect`, after setting up the MutationObserver, call `watchRenderingAndReScroll(scrollElement, scrollToKeyword)` where `scrollToKeyword` calls `scrollToTargetWithinContainer` on the first `.highlighted-keyword` element
+  - Add `[page._id]` to the dependency array (currently has no deps) and return the watch cleanup function
+  - Remove the hash guard (`if (window.location.hash.length > 0) return`) — no longer needed once `useContentAutoScroll` is removed
+  - _See research.md for proposed code sketch_
+
+- [x] 7.2 Update SearchResultContent tests
+  - Remove tests that assert `useContentAutoScroll` is called
+  - Add tests that `watchRenderingAndReScroll` re-scrolls to `.highlighted-keyword` after a rendering element settles
+  - Update MutationObserver suppression test: remove the hash-guard test (guard will be gone)
+
+- [x] 8. Reorganize auto-scroll modules by co-locating hooks with their consumers
+- [x] 8.1 Move the rendering watch utility to the shared utility directory
+  - Move the rendering watch function and its test file from the shared hooks directory to the client utility directory, alongside the existing smooth-scroll utility
+  - Update the import path in the hash-based auto-scroll hook to reference the new location
+  - Update the import path in SearchResultContent to reference the new location
+  - Run existing tests to verify no regressions from the path change
+  - _Requirements: 5.4, 5.5_
+- [x] 8.2 Rename and move the hash-based auto-scroll hook next to PageView
+  - Rename the hook and its options type to reflect its hash-navigation–specific purpose (not a generic "content auto-scroll")
+  - Move the hook file and its test file to the PageView component directory
+  - Update PageView's import to use the co-located hook with the new name
+  - Update the hook's internal import of the rendering watch utility to use the path established in 8.1
+  - Run existing tests to verify the rename and move introduce no regressions
+  - _Requirements: 5.4, 5.5_
+- [x] 8.3 Extract the keyword-scroll effect from SearchResultContent into a co-located hook
+  - Create a new hook that encapsulates the MutationObserver-based keyword detection, debounced scroll, and rendering watch integration currently inlined in the component
+  - Accept a ref to the scrollable container and a trigger key as inputs
+  - Move the scroll helper functions (container-relative scroll calculation, first-highlighted-keyword lookup) into the hook file if they are used only by this logic
+  - Replace the inline useEffect in SearchResultContent with a single call to the new hook
+  - _Requirements: 5.4, 5.5, 6.1_
+- [x] 8.4 (P) Write tests for the extracted keyword-rescroll hook
+  - Migrate rendering watch assertions from SearchResultContent tests into the new hook's test file
+  - Add tests for keyword scroll behavior: MutationObserver setup, debounced scroll to the first highlighted keyword, cleanup on key change and unmount
+  - Simplify SearchResultContent tests to verify the hook is called with the correct container ref and key, without re-testing internal scroll behavior
+  - _Requirements: 6.1, 6.2_
+- [x] 8.5 (P) Remove the old shared hooks directory and verify no stale imports
+  - Delete the now-empty auto-scroll hooks directory
+  - Search the codebase for any remaining references to the old directory path and fix them
+  - Run the full test suite and type check to confirm the reorganization is complete
+  - _Requirements: 5.5_

+ 268 - 0
.kiro/specs/collaborative-editor/design.md

@@ -0,0 +1,268 @@
+# Design Document: collaborative-editor
+
+## Overview
+
+**Purpose**: Real-time collaborative editing in GROWI, allowing multiple users to simultaneously edit the same wiki page with automatic conflict resolution via Yjs CRDT.
+
+**Users**: All GROWI users who use real-time collaborative page editing. System operators manage the WebSocket and persistence infrastructure.
+
+**Impact**: Yjs document synchronization over native WebSocket (`y-websocket`), with Socket.IO continuing to serve non-Yjs real-time events (page room broadcasts, notifications).
+
+### Goals
+- Guarantee a single server-side Y.Doc per page — no split-brain desynchronization
+- Provide real-time bidirectional sync for all connected editors
+- Authenticate and authorize WebSocket connections using existing session infrastructure
+- Persist draft state to MongoDB for durability across reconnections and restarts
+- Bridge awareness/presence events to non-editor UI via Socket.IO rooms
+
+### Non-Goals
+- Changing the Yjs document model, CodeMirror integration, or page save/revision logic
+- Migrating Socket.IO-based UI events to WebSocket
+- Changing the `yjs-writings` MongoDB collection schema or data format
+
+## Architecture
+
+### Architecture Diagram
+
+```mermaid
+graph TB
+    subgraph Client
+        CM[CodeMirror Editor]
+        WP[WebsocketProvider]
+        GS[Global Socket.IO Client]
+    end
+
+    subgraph Server
+        subgraph HTTP Server
+            Express[Express App]
+            SIO[Socket.IO Server]
+            WSS[WebSocket Server - ws]
+        end
+
+        subgraph YjsService
+            UpgradeHandler[Upgrade Handler - Auth]
+            ConnHandler[Connection Handler]
+            DocManager[Document Manager - getYDoc]
+            AwarenessBridge[Awareness Bridge]
+        end
+
+        MDB[(MongoDB - yjs-writings)]
+        SessionStore[(Session Store)]
+    end
+
+    CM --> WP
+    WP -->|ws path yjs pageId| WSS
+    GS -->|socket.io| SIO
+
+    WSS -->|upgrade auth| UpgradeHandler
+    UpgradeHandler -->|parse cookie| SessionStore
+    WSS -->|connection| ConnHandler
+    ConnHandler --> DocManager
+    DocManager --> MDB
+
+    AwarenessBridge -->|io.in room .emit| SIO
+
+    DocManager -->|awareness events| AwarenessBridge
+```
+
+**Key architectural properties**:
+- **Dual transport**: WebSocket for Yjs sync (`/yjs/{pageId}`), Socket.IO for UI events (`/socket.io/`)
+- **Singleton YjsService**: Encapsulates all Yjs document management
+- **Atomic document creation**: `map.setIfUndefined` from lib0 — synchronous get-or-create, no race condition window
+- **Session-based auth**: Cookie parsed from HTTP upgrade request, same session store as Express
+
+### Technology Stack
+
+| Layer | Choice / Version | Role |
+|-------|------------------|------|
+| Client Provider | `y-websocket@^2.x` (WebsocketProvider) | Yjs document sync over WebSocket |
+| Server WebSocket | `ws@^8.x` (WebSocket.Server) | Native WebSocket server, `noServer: true` mode |
+| Server Yjs Utils | `y-websocket@^2.x` (`bin/utils`) | `setupWSConnection`, `getYDoc`, `WSSharedDoc` |
+| Persistence | `y-mongodb-provider` (extended) | Yjs document persistence to `yjs-writings` collection |
+| Event Bridge | Socket.IO `io` instance | Awareness state broadcasting to page rooms |
+| Auth | express-session + passport | WebSocket upgrade authentication via cookie |
+
+## System Flows
+
+### Client Connection Flow
+
+```mermaid
+sequenceDiagram
+    participant C as Client Browser
+    participant WSS as WebSocket Server
+    participant UH as Upgrade Handler
+    participant SS as Session Store
+    participant DM as Document Manager
+    participant MDB as MongoDB
+
+    C->>WSS: HTTP Upgrade GET /yjs/pageId
+    WSS->>UH: upgrade event
+    UH->>SS: Parse cookie, load session
+    SS-->>UH: Session with user
+    UH->>UH: Check page access
+    alt Unauthorized
+        UH-->>C: 401/403, destroy socket
+    else Authorized
+        UH->>WSS: handleUpgrade
+        WSS->>DM: setupWSConnection
+        DM->>DM: getYDoc - atomic get or create
+        alt New document
+            DM->>MDB: bindState - load persisted state
+            MDB-->>DM: Y.Doc state
+        end
+        DM-->>C: Sync Step 1 - state vector
+        C-->>DM: Sync Step 2 - diff
+        DM-->>C: Awareness states
+    end
+```
+
+Authentication happens before `handleUpgrade` — unauthorized connections never reach the Yjs layer. Document creation uses `getYDoc`'s atomic `map.setIfUndefined` pattern.
+
+### Document Lifecycle
+
+```mermaid
+stateDiagram-v2
+    [*] --> Created: First client connects
+    Created --> Active: bindState completes
+    Active --> Active: Clients connect/disconnect
+    Active --> Flushing: Last client disconnects
+    Flushing --> [*]: writeState completes, doc destroyed
+    Flushing --> Active: New client connects before destroy
+```
+
+## Components and Interfaces
+
+| Component | Layer | Intent | Key Dependencies |
+|-----------|-------|--------|-----------------|
+| YjsService | Server / Service | Orchestrates Yjs document lifecycle, exposes public API | ws, y-websocket/bin/utils, MongodbPersistence |
+| UpgradeHandler | Server / Auth | Authenticates and authorizes WebSocket upgrade requests | express-session, passport, Page model |
+| guardSocket | Server / Util | Prevents socket closure by other upgrade handlers during async auth | — |
+| PersistenceAdapter | Server / Data | Bridges MongodbPersistence to y-websocket persistence interface | MongodbPersistence, syncYDoc, Socket.IO io |
+| AwarenessBridge | Server / Events | Bridges y-websocket awareness events to Socket.IO rooms | Socket.IO io |
+| use-collaborative-editor-mode | Client / Hook | Manages WebsocketProvider lifecycle and awareness | y-websocket, yjs |
+
+### YjsService
+
+**Intent**: Manages Yjs document lifecycle, WebSocket server setup, and public API for page save/status integration.
+
+**Responsibilities**:
+- Owns the `ws.WebSocketServer` instance and the y-websocket `docs` Map
+- Initializes persistence via y-websocket's `setPersistence`
+- Registers the HTTP `upgrade` handler (delegating auth to UpgradeHandler)
+- Exposes the same public interface as `IYjsService` for downstream consumers
+
+**Service Interface**:
+
+```typescript
+interface IYjsService {
+  getYDocStatus(pageId: string): Promise<YDocStatus>;
+  syncWithTheLatestRevisionForce(
+    pageId: string,
+    editingMarkdownLength?: number,
+  ): Promise<SyncLatestRevisionBody>;
+  getCurrentYdoc(pageId: string): Y.Doc | undefined;
+}
+```
+
+- Constructor accepts `httpServer: http.Server` and `io: Server`
+- Uses `WebSocket.Server({ noServer: true })` + y-websocket utils
+- Uses `httpServer.on('upgrade', ...)` with path check for `/yjs/`
+- **CRITICAL**: Socket.IO server must set `destroyUpgrade: false` to prevent engine.io from destroying non-Socket.IO upgrade requests
+
+### UpgradeHandler
+
+**Intent**: Authenticates WebSocket upgrade requests using session cookies and verifies page access.
+
+**Interface**:
+
+```typescript
+type UpgradeResult =
+  | { authorized: true; request: AuthenticatedRequest; pageId: string }
+  | { authorized: false; statusCode: number };
+```
+
+- Runs express-session and passport middleware via `runMiddleware` helper against raw `IncomingMessage`
+- `writeErrorResponse` writes HTTP status line only — socket cleanup deferred to caller (works with `guardSocket`)
+- Guest access: if `user` is undefined but page allows guest access, authorization proceeds
+
+### guardSocket
+
+**Intent**: Prevents other synchronous upgrade handlers from closing the socket during async auth.
+
+**Why this exists**: Node.js EventEmitter fires all `upgrade` listeners synchronously. When the Yjs async handler yields at its first `await`, Next.js's `NextCustomServer.upgradeHandler` runs and calls `socket.end()` for unrecognized paths. This destroys the socket before Yjs auth completes.
+
+**How it works**: Temporarily replaces `socket.end()` and `socket.destroy()` with no-ops before the first `await`. After auth completes, `restore()` reinstates the original methods.
+
+```typescript
+const guard = guardSocket(socket);
+const result = await handleUpgrade(request, socket, head);
+guard.restore();
+```
+
+### PersistenceAdapter
+
+**Intent**: Adapts MongodbPersistence to y-websocket's persistence interface (`bindState`, `writeState`).
+
+**Interface**:
+
+```typescript
+interface YWebsocketPersistence {
+  bindState: (docName: string, ydoc: Y.Doc) => void;
+  writeState: (docName: string, ydoc: Y.Doc) => Promise<void>;
+  provider: MongodbPersistence;
+}
+```
+
+**Key behavior**:
+- `bindState`: Loads persisted state → determines YDocStatus → calls `syncYDoc` → registers awareness event bridge
+- `writeState`: Flushes document state to MongoDB on last-client disconnect
+- Ordering within `bindState` is guaranteed (persistence load → sync → awareness registration)
+
+### AwarenessBridge
+
+**Intent**: Bridges y-websocket per-document awareness events to Socket.IO room broadcasts.
+
+**Published events** (to Socket.IO rooms):
+- `YjsAwarenessStateSizeUpdated` with `awarenessStateSize: number`
+- `YjsHasYdocsNewerThanLatestRevisionUpdated` with `hasNewerYdocs: boolean`
+
+**Subscribed events** (from y-websocket):
+- `WSSharedDoc.awareness.on('update', ...)` — per-document awareness changes
+
+### use-collaborative-editor-mode (Client Hook)
+
+**Intent**: Manages WebsocketProvider lifecycle, awareness state, and CodeMirror extensions.
+
+**Key details**:
+- WebSocket URL: `${wsProtocol}//${window.location.host}/yjs`, room name: `pageId`
+- Options: `connect: true`, `resyncInterval: 3000`
+- Awareness API: `provider.awareness.setLocalStateField`, `.on('update', ...)`
+- All side effects (provider creation, awareness setup) must be outside React state updaters to avoid render-phase violations
+
+## Data Models
+
+No custom data models. Uses the existing `yjs-writings` MongoDB collection via `MongodbPersistence` (extended `y-mongodb-provider`). Collection schema, indexes, and persistence interface (`bindState` / `writeState`) are unchanged.
+
+## Error Handling
+
+| Error Type | Scenario | Response |
+|------------|----------|----------|
+| Auth Failure | Invalid/expired session cookie | 401 on upgrade, socket destroyed |
+| Access Denied | User lacks page access | 403 on upgrade, socket destroyed |
+| Persistence Error | MongoDB read failure in bindState | Log error, serve empty doc (clients sync from each other) |
+| WebSocket Close | Client network failure | Automatic reconnect with exponential backoff (WebsocketProvider built-in) |
+| Document Not Found | getCurrentYdoc for non-active doc | Return undefined |
+
+## Requirements Traceability
+
+| Requirement | Summary | Components |
+|-------------|---------|------------|
+| 1.1, 1.2 | Single Y.Doc per page | DocumentManager (getYDoc atomic pattern) |
+| 1.3, 1.4, 1.5 | Sync integrity on reconnect | DocumentManager, WebsocketProvider |
+| 2.1, 2.2 | y-websocket transport | YjsService, use-collaborative-editor-mode |
+| 2.3 | Coexist with Socket.IO | UpgradeHandler, guardSocket |
+| 2.4 | resyncInterval | WebsocketProvider |
+| 3.1-3.4 | Auth on upgrade | UpgradeHandler |
+| 4.1-4.5 | MongoDB persistence | PersistenceAdapter |
+| 5.1-5.4 | Awareness and presence | AwarenessBridge, use-collaborative-editor-mode |
+| 6.1-6.4 | YDoc status and sync | YjsService |

+ 79 - 0
.kiro/specs/collaborative-editor/requirements.md

@@ -0,0 +1,79 @@
+# Requirements Document
+
+## Introduction
+
+GROWI provides real-time collaborative editing powered by Yjs, allowing multiple users to simultaneously edit the same wiki page with automatic conflict resolution. The collaborative editing system uses `y-websocket` as the Yjs transport layer over native WebSocket, with MongoDB persistence for draft state and Socket.IO bridging for awareness/presence events to non-editor UI components.
+
+**Scope**: Server-side Yjs document management, client-side Yjs provider, WebSocket authentication, MongoDB persistence integration, and awareness/presence tracking.
+
+**Out of Scope**: The Yjs document model itself, CodeMirror editor integration details, page save/revision logic, or the global Socket.IO infrastructure used for non-Yjs events.
+
+## Requirements
+
+### Requirement 1: Document Synchronization Integrity
+
+**Objective:** As a wiki user editing collaboratively, I want all clients editing the same page to always share a single server-side Y.Doc instance, so that edits are never lost due to document desynchronization.
+
+#### Acceptance Criteria
+
+1. When multiple clients connect to the same page simultaneously, the Yjs Service shall ensure that exactly one Y.Doc instance exists on the server for that page.
+2. When a client connects while another client's document initialization is in progress, the Yjs Service shall return the same Y.Doc instance to both clients without creating a duplicate.
+3. When a client reconnects after a brief network disconnection, the Yjs Service shall synchronize the client with the existing server-side Y.Doc containing all other clients' changes.
+4. While multiple clients are editing the same page, the Yjs Service shall propagate each client's changes to all other connected clients in real time.
+5. If a client's WebSocket connection drops and reconnects, the Yjs Service shall not destroy the server-side Y.Doc while other clients remain connected.
+
+### Requirement 2: WebSocket Transport Layer
+
+**Objective:** As a system operator, I want the collaborative editing transport to use y-websocket over native WebSocket, so that the system benefits from active maintenance and atomic document initialization.
+
+#### Acceptance Criteria
+
+1. The Yjs Service shall use `y-websocket` server utilities as the server-side Yjs transport.
+2. The Editor Client shall use `y-websocket`'s `WebsocketProvider` as the client-side Yjs provider.
+3. The WebSocket server shall coexist with the existing Socket.IO server on the same HTTP server instance without port conflicts.
+4. The Yjs Service shall support `resyncInterval` (periodic state re-synchronization) to recover from any missed updates.
+
+### Requirement 3: Authentication and Authorization
+
+**Objective:** As a system administrator, I want WebSocket connections for collaborative editing to be authenticated and authorized, so that only permitted users can access page content via the Yjs channel.
+
+#### Acceptance Criteria
+
+1. When a WebSocket upgrade request is received for collaborative editing, the Yjs Service shall authenticate the user using the existing session/passport mechanism.
+2. When an authenticated user attempts to connect to a page's Yjs document, the Yjs Service shall verify that the user has read access to that page before allowing the connection.
+3. If an unauthenticated or unauthorized WebSocket upgrade request is received, the Yjs Service shall reject the connection with an appropriate HTTP error status.
+4. Where guest access is enabled for a page, the Yjs Service shall allow guest users to connect to that page's collaborative editing session.
+
+### Requirement 4: MongoDB Persistence
+
+**Objective:** As a system operator, I want the Yjs persistence layer to use MongoDB storage, so that draft state is preserved across server restarts and client reconnections.
+
+#### Acceptance Criteria
+
+1. The Yjs Service shall use the `yjs-writings` MongoDB collection for document persistence.
+2. The Yjs Service shall use the `MongodbPersistence` implementation (extended `y-mongodb-provider`).
+3. When a Y.Doc is loaded from persistence, the Yjs Service shall apply the persisted state before sending sync messages to connecting clients.
+4. When a Y.Doc receives updates, the Yjs Service shall persist each update to MongoDB with an `updatedAt` timestamp.
+5. When all clients disconnect from a document, the Yjs Service shall flush the document state to MongoDB before destroying the in-memory instance.
+
+### Requirement 5: Awareness and Presence Tracking
+
+**Objective:** As a wiki user, I want to see which other users are currently editing the same page, so that I can coordinate edits and avoid conflicts.
+
+#### Acceptance Criteria
+
+1. While a user is editing a page, the Editor Client shall broadcast the user's presence information (name, username, avatar, cursor color) via the Yjs awareness protocol.
+2. When a user connects or disconnects from a collaborative editing session, the Yjs Service shall emit awareness state size updates to the page's Socket.IO room (`page:{pageId}`).
+3. When the last user disconnects from a document, the Yjs Service shall emit a draft status notification (`YjsHasYdocsNewerThanLatestRevisionUpdated`) to the page's Socket.IO room.
+4. The Editor Client shall display the list of active editors based on awareness state updates from the Yjs provider.
+
+### Requirement 6: YDoc Status and Sync Integration
+
+**Objective:** As a system component, I want the YDoc status detection and force-sync mechanisms to function correctly, so that draft detection, save operations, and revision synchronization work as expected.
+
+#### Acceptance Criteria
+
+1. The Yjs Service shall expose `getYDocStatus(pageId)` returning the correct status (ISOLATED, NEW, DRAFT, SYNCED, OUTDATED).
+2. The Yjs Service shall expose `getCurrentYdoc(pageId)` returning the in-memory Y.Doc instance if one exists.
+3. When a Y.Doc is loaded from persistence (within `bindState`), the Yjs Service shall call `syncYDoc` to synchronize the document with the latest revision based on YDoc status.
+4. The Yjs Service shall expose `syncWithTheLatestRevisionForce(pageId)` for API-triggered force synchronization.

+ 69 - 0
.kiro/specs/collaborative-editor/research.md

@@ -0,0 +1,69 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `collaborative-editor`
+- **Key Findings**:
+  - y-websocket uses atomic `map.setIfUndefined` for document creation — eliminates TOCTOU race conditions
+  - `y-websocket@2.x` bundles both client and server utils with `yjs@^13` compatibility
+  - `ws` package already installed in GROWI; Express HTTP server supports adding WebSocket upgrade alongside Socket.IO
+
+## Design Decisions
+
+### Decision: Use y-websocket@2.x for both client and server
+
+- **Context**: Need yjs v13 compatibility on both client and server sides
+- **Alternatives Considered**:
+  1. y-websocket@3.x client + custom server — more work, v3 SyncStatus not needed
+  2. y-websocket@3.x + @y/websocket-server — requires yjs v14 migration (out of scope)
+  3. y-websocket@2.x for everything — simplest path, proven code
+- **Selected**: Option 3 — `y-websocket@2.x`
+- **Rationale**: Minimizes custom code, proven server utils, compatible with yjs v13, clear upgrade path to v3 + @y/websocket-server when yjs v14 migration happens
+- **Trade-offs**: Miss v3 SyncStatus feature, but `sync` event + `resyncInterval` meets all requirements
+- **Follow-up**: Plan separate yjs v14 migration, then upgrade to y-websocket v3 + @y/websocket-server
+
+### Decision: WebSocket path prefix `/yjs/`
+
+- **Context**: Need URL pattern that doesn't conflict with Socket.IO
+- **Selected**: `/yjs/{pageId}`
+- **Rationale**: Simple, semantic, no conflict with Socket.IO's `/socket.io/` path or Express routes
+
+### Decision: Session-based authentication on WebSocket upgrade
+
+- **Context**: Must authenticate WebSocket connections without Socket.IO middleware
+- **Selected**: Parse session cookie from HTTP upgrade request, deserialize user from session store
+- **Rationale**: Reuses existing session infrastructure — same cookie, same store, same passport serialization
+- **Trade-offs**: Couples to express-session internals, but GROWI already has this coupling throughout
+
+### Decision: Keep Socket.IO for awareness event fan-out
+
+- **Context**: GROWI uses Socket.IO rooms (`page:{pageId}`) to broadcast awareness updates to non-editor components
+- **Selected**: Continue using Socket.IO `io.in(roomName).emit()` for awareness events, bridging from y-websocket awareness
+- **Rationale**: Non-editor UI components already listen on Socket.IO rooms; changing this is out of scope
+
+## Critical Implementation Constraints
+
+### engine.io `destroyUpgrade` setting
+
+Socket.IO's engine.io v6 defaults `destroyUpgrade: true` in its `attach()` method. This causes engine.io to destroy all non-Socket.IO upgrade requests after a 1-second timeout. The Socket.IO server **must** be configured with `destroyUpgrade: false` to allow `/yjs/` WebSocket handshakes to succeed.
+
+### Next.js upgradeHandler race condition (guardSocket pattern)
+
+Next.js's `NextCustomServer.upgradeHandler` registers an `upgrade` listener on the HTTP server. When the Yjs async handler yields at its first `await`, Next.js's synchronous handler runs and calls `socket.end()` for unrecognized paths. The `guardSocket` pattern temporarily replaces `socket.end()`/`socket.destroy()` with no-ops before the first `await`, restoring them after auth completes.
+
+- `prependListener` cannot solve this — it only changes listener order, cannot prevent subsequent listeners from executing
+- Removing Next.js's listener is fragile and breaks HMR
+- Synchronous auth is impossible (requires async MongoDB/session store queries)
+
+### React render-phase violation in use-collaborative-editor-mode
+
+Provider creation and awareness event handlers must be placed **outside** `setProvider(() => { ... })` functional state updaters. If inside, `awareness.setLocalStateField()` triggers synchronous awareness events that update other components during render. All side effects go in the `useEffect` body; `setProvider(_provider)` is called with a plain value.
+
+### y-websocket bindState ordering
+
+y-websocket does NOT await `bindState` before sending sync messages. However, within `bindState` itself, the ordering is guaranteed: persistence load → YDocStatus check → syncYDoc → awareness registration. This consolidation is intentional.
+
+## References
+- [y-websocket GitHub](https://github.com/yjs/y-websocket)
+- [y-websocket-server GitHub](https://github.com/yjs/y-websocket-server) (yjs v14, future migration target)
+- [ws npm](https://www.npmjs.com/package/ws)
+- [y-mongodb-provider](https://github.com/MaxNoetzold/y-mongodb-provider)

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

@@ -0,0 +1,22 @@
+{
+  "feature_name": "collaborative-editor",
+  "created_at": "2026-03-19T00:00:00.000Z",
+  "updated_at": "2026-03-24T00:00:00.000Z",
+  "language": "en",
+  "phase": "active",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 3 - 0
.kiro/specs/collaborative-editor/tasks.md

@@ -0,0 +1,3 @@
+# Implementation Plan
+
+No pending tasks. Use `/kiro:spec-tasks collaborative-editor` to generate tasks for new work.

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

+ 108 - 0
.kiro/specs/news-inappnotification/requirements.md

@@ -0,0 +1,108 @@
+# Requirements Document
+
+## Introduction
+
+GROWI の InAppNotification にニュース配信・表示機能を追加する。外部の静的 JSON フィード(GitHub Pages)を GROWI 本体が cron で定期取得し、ローカル MongoDB にキャッシュした上で、InAppNotificationパネルおよび通知一覧ページにニュースとして表示する。
+
+ニュースは既存の InAppNotification とは別モデル(NewsItem)として管理する。InAppNotification はユーザーアクション起因で関係者のみに配信されるのに対し、ニュースは全ユーザー(またはロール単位)に配信されるため、1件のニュースを全ユーザーで共有する設計が SaaS 規模で効率的である。UI ではクライアント側で両データを時系列マージして統合表示する。
+
+## Requirements
+
+### Requirement 1: ニュースフィードの定期取得
+
+**Objective:** As a GROWI 運営者, I want GROWI が外部フィードからニュースを自動取得する, so that 各 GROWI インスタンスに最新のニュースが配信される
+
+#### Acceptance Criteria
+
+1. When cron スケジュールの実行時刻に達した場合, the News Cron Service shall 設定された URL から JSON フィードを HTTP GET で取得する
+2. When フィードの取得に成功した場合, the News Cron Service shall 取得したニュースアイテムをローカル MongoDB に upsert(`externalId` で重複排除)する
+3. When フィードに含まれなくなったニュースアイテムがある場合, the News Cron Service shall 該当アイテムをローカル DB から削除する
+4. When 複数の GROWI インスタンスが同時に取得を試みる場合, the News Cron Service shall ランダムスリープにより配信元へのリクエストを時間分散する
+5. If フィードの取得に失敗した場合, then the News Cron Service shall エラーをログに記録し、既存のキャッシュデータを維持する
+6. Where `NEWS_FEED_URL` が未設定または空の場合, the News Cron Service shall フィード取得をスキップしエラーなく動作する
+7. When ニュースアイテムに `growiVersionRegExps` 条件が設定されている場合, the News Cron Service shall 現在の GROWI バージョンと照合し、一致しないアイテムを除外する
+
+### Requirement 2: ニュースアイテムのローカルキャッシュ
+
+**Objective:** As a GROWI システム, I want 取得したニュースをローカル DB にキャッシュする, so that フィード配信元に障害が起きてもニュースを表示できる
+
+**Note:** NewsItem を既存の InAppNotification モデルで代替できない理由:①外部フィード由来コンテンツの重複排除に必要な `externalId`(ユニークインデックス)が InAppNotification に存在しない。②InAppNotification は per-user ドキュメント設計のため、ニュースに適用すると配信時点で全ユーザー分のドキュメントを強制生成する必要がある(例: 1000ユーザー × 10件 = 10,000件、さらに `snapshot` にニュース本文がユーザー数分コピーされる)。NewsItem は全ユーザーで1件を共有するため、SaaS規模で効率的である。③TTL管理(90日)はニュース固有の要件。
+
+#### Acceptance Criteria
+
+1. The NewsItem モデル shall `externalId` にユニークインデックスを持ち、重複登録を防止する
+2. The NewsItem モデル shall `publishedAt` にインデックスを持ち、公開日時順のソートを効率的に行う
+3. The NewsItem モデル shall `fetchedAt` に TTL インデックス(90日)を持ち、古いニュースを自動削除する
+4. The NewsItem モデル shall 多言語対応のタイトル・本文(`ja_JP`, `en_US`)を格納できる
+
+### Requirement 3: 既読/未読管理
+
+**Objective:** As a GROWI ユーザー, I want ニュースの既読/未読状態を管理したい, so that 新しいニュースを見逃さない
+
+**Note:** NewsReadStatus を既存の InAppNotification モデルで代替できない理由:InAppNotification の `status` フィールドは per-user ドキュメントに依存しており、ニュースの既読状態を管理するには配信時に全ユーザー分のドキュメントを作成しなければならない(1000ユーザー × 10件 = 配信時点で強制的に 10,000件)。NewsReadStatus はユーザーが実際に既読アクションを起こした時のみ作成される(未読はレコードなし)。全員が全件読まない限り実際のレコード数は常に 10,000件を下回り、SaaS規模でのストレージ効率が高い。
+
+#### Acceptance Criteria
+
+1. When ユーザーがニュースアイテムをクリックした場合, the News API shall 該当ユーザーとニュースアイテムの組み合わせで `NewsReadStatus` レコードを作成する
+2. While `NewsReadStatus` レコードが存在しない場合, the News API shall 該当ニュースを未読として扱う
+3. The NewsReadStatus モデル shall `userId + newsItemId` の複合ユニークインデックスにより重複登録を防止する
+4. When ニュース一覧を取得する場合, the News API shall 各ニュースアイテムに `isRead: true/false` を付与して返却する
+5. The News API shall ログインユーザーの未読ニュース数を返却するエンドポイントを提供する
+
+### Requirement 4: ロール別表示制御
+
+**Objective:** As a GROWI 運営者, I want ニュースの表示対象をロールで制御したい, so that 管理者向け情報を一般ユーザーに見せない
+
+**Note:** 表示制御はニュース配信側(GROWI運営)がフィードJSON内の `conditions.targetRoles` で指定する。インスタンス側(GROWI管理者)による制御は設けない。
+
+#### Acceptance Criteria
+
+1. When ニュースアイテムに `conditions.targetRoles` が設定されている場合, the News API shall ユーザーのロール(admin/general)に基づいてフィルタリングする
+2. When ニュースアイテムに `conditions.targetRoles` が未設定の場合, the News API shall 全ユーザーにニュースを表示する
+
+### Requirement 5: InAppNotification UI 統合表示
+
+**Objective:** As a GROWI ユーザー, I want 既存の InAppNotification UI でニュースを確認したい, so that 通知と同じ導線でニュースにアクセスできる
+
+**Note:** NewsItem と InAppNotification は別モデルとして維持する。UI のみクライアント側で両データを時系列マージして表示する。
+
+#### Acceptance Criteria
+
+1. The InAppNotificationパネル shall 通知とニュースを公開日時/作成日時の降順で混合した1つのリストとして表示する
+2. The InAppNotificationパネル shall 上部にフィルタボタン(「すべて」「通知」「お知らせ」)を配置し、デフォルトは「すべて」とする。「お知らせ」選択時はニュースのみ、「通知」選択時はニュース以外のすべての通知を表示する
+3. The InAppNotificationパネル shall 既存の「未読のみ」トグルスイッチを維持し、種別フィルタと組み合わせた2重フィルタリングを提供する。種別フィルタ(すべて/通知/お知らせ)で表示対象を絞り込んだ上で、トグルON時は未読アイテムのみをさらに絞り込む
+4. The InAppNotificationパネル shall リスト領域に最大高さを設定し、超過分はスクロールで表示する。スクロールが末端に達した場合は次のページを自動で読み込む無限スクロールとする
+5. The InAppNotificationパネル shall ニュースアイテムの `type` に応じた絵文字アイコンをタイトル前に表示する(`release`→🎉, `security`→⚠️, `tips`→💡, `maintenance`→🔧, `announcement`→📢, 未設定→📢)
+6. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall ニュースの詳細 URL を新しいタブで開く
+7. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall 該当ニュースを既読としてマークし、未読インジケータを更新する
+
+### Requirement 6: 既読/未読の視覚表示
+
+**Objective:** As a GROWI ユーザー, I want 未読のニュース・通知を視覚的に区別したい, so that 未確認の項目をすぐに見分けられる
+
+#### Acceptance Criteria
+
+1. The 未読アイテム shall タイトルを太字(`fw-bold`)で表示する
+2. The 未読アイテム shall 左端に青色の丸ドット(8px, `bg-primary`)を表示する
+3. The 既読アイテム shall タイトルを通常ウェイト(`fw-normal`)で表示する
+4. The 既読アイテム shall ドットと同じ幅の透明スペーサーを配置し、インデントを統一する
+
+### Requirement 7: 未読バッジ表示
+
+**Objective:** As a GROWI ユーザー, I want 未読ニュースの存在をバッジで把握したい, so that 新しいニュースがあることに気づける
+
+#### Acceptance Criteria
+
+1. The サイドバー通知アイコン shall 通知の未読数とニュースの未読数を合算してバッジに表示する
+2. When 全てのニュースが既読の場合, the バッジ shall ニュース分のカウントを含めない
+
+### Requirement 8: 多言語対応
+
+**Objective:** As a GROWI ユーザー, I want ニュースを自分の言語で読みたい, so that 内容を正しく理解できる
+
+#### Acceptance Criteria
+
+1. When ニュースアイテムに複数言語のテキストが含まれる場合, the NewsItem コンポーネント shall ブラウザの言語設定に応じたテキストを表示する
+2. If ブラウザの言語に対応するテキストが存在しない場合, then the NewsItem コンポーネント shall `ja_JP` → `en_US` の順にフォールバックする
+3. The UI ラベル(「ニュース」「ニュースはありません。」等)shall `ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR` の i18n ロケールファイルで提供する
+4. The フィルタボタン用ラベル(「通知」「お知らせ」)shall 全対応言語のロケールファイルに追加する

+ 22 - 0
.kiro/specs/news-inappnotification/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "news-inappnotification",
+  "created_at": "2026-03-24T00:00:00Z",
+  "updated_at": "2026-03-24T01:00:00Z",
+  "language": "ja",
+  "phase": "requirements-generated",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": false
+    },
+    "design": {
+      "generated": false,
+      "approved": false
+    },
+    "tasks": {
+      "generated": false,
+      "approved": false
+    }
+  },
+  "ready_for_implementation": false
+}

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

+ 284 - 0
.kiro/specs/presentation/design.md

@@ -0,0 +1,284 @@
+# Design Document: presentation
+
+## Overview
+
+**Purpose**: This feature decouples heavy Marp rendering dependencies (`@marp-team/marp-core`, `@marp-team/marpit`) from the common slide rendering path, so they are loaded only when a page explicitly uses `marp: true` frontmatter.
+
+**Users**: All GROWI users benefit from reduced JavaScript payload when viewing slide pages that do not use Marp. Developers benefit from clearer module boundaries.
+
+**Impact**: Changes the `@growi/presentation` package's internal import structure. No external API or behavioral changes.
+
+### Goals
+- Eliminate `@marp-team/marp-core` and `@marp-team/marpit` from the GrowiSlides module graph
+- Load MarpSlides and its Marp dependencies only on demand via dynamic import
+- Maintain identical rendering behavior for all slide types
+
+### Non-Goals
+- Optimizing the `useSlidesByFrontmatter` hook (already lightweight with internal dynamic imports)
+- Changing the app-level lazy-loading architecture (`useLazyLoader`, `next/dynamic` for the modal)
+- Modifying the Marp rendering logic or configuration itself
+- Reducing the size of `@marp-team/marp-core` internals
+
+## Architecture
+
+### Existing Architecture Analysis
+
+Current module dependency graph for `Slides.tsx`:
+
+```mermaid
+graph TD
+  Slides[Slides.tsx]
+  MarpSlides[MarpSlides.tsx]
+  GrowiSlides[GrowiSlides.tsx]
+  GrowiMarpit[growi-marpit.ts]
+  MarpCore[marp-team/marp-core]
+  Marpit[marp-team/marpit]
+
+  Slides --> MarpSlides
+  Slides --> GrowiSlides
+  MarpSlides --> GrowiMarpit
+  GrowiSlides --> GrowiMarpit
+  GrowiMarpit --> MarpCore
+  GrowiMarpit --> Marpit
+```
+
+**Problem**: Both `MarpSlides` and `GrowiSlides` statically import `growi-marpit.ts`, which instantiates Marp at module scope. This forces ~896KB of Marp modules to load even when rendering non-Marp slides.
+
+**Constraints**:
+- `growi-marpit.ts` exports both runtime Marp instances and a simple string constant (`MARP_CONTAINER_CLASS_NAME`)
+- `GrowiSlides` uses Marp only for CSS extraction (`marpit.render('')`), not for content rendering
+- The package uses Vite with `preserveModules: true` and `nodeExternals({ devDeps: true })`
+
+### Architecture Pattern & Boundary Map
+
+Target module dependency graph:
+
+```mermaid
+graph TD
+  Slides[Slides.tsx]
+  MarpSlides[MarpSlides.tsx]
+  GrowiSlides[GrowiSlides.tsx]
+  GrowiMarpit[growi-marpit.ts]
+  MarpCore[marp-team/marp-core]
+  Marpit[marp-team/marpit]
+  Consts[consts/index.ts]
+  BaseCss[consts/marpit-base-css.ts]
+
+  Slides -.->|React.lazy| MarpSlides
+  Slides --> GrowiSlides
+  MarpSlides --> GrowiMarpit
+  GrowiMarpit --> MarpCore
+  GrowiMarpit --> Marpit
+  GrowiMarpit --> Consts
+  GrowiSlides --> Consts
+  GrowiSlides --> BaseCss
+```
+
+**Key changes**:
+- Dashed arrow = dynamic import (React.lazy), solid arrow = static import
+- `GrowiSlides` no longer imports `growi-marpit.ts`; uses pre-extracted CSS from `marpit-base-css.ts`
+- `MARP_CONTAINER_CLASS_NAME` moves to shared `consts/index.ts`
+- Marp modules are only reachable through the dynamic `MarpSlides` path
+
+**Steering compliance**:
+- Follows "subpath imports over barrel imports" principle
+- Aligns with `next/dynamic({ ssr: false })` technique from build-optimization skill
+- Preserves existing lazy-loading boundaries at app level
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Frontend | React 18 (`React.lazy`, `Suspense`) | Dynamic import boundary for MarpSlides | Standard React code-splitting; SSR not a concern (see research.md) |
+| Build | Vite (existing) | Package compilation with `preserveModules` | Dynamic `import()` preserved in output |
+| Build script | Node.js ESM (.mjs) | CSS extraction at build time | No additional tooling dependency |
+
+## System Flows
+
+### Slide Rendering Decision Flow
+
+```mermaid
+flowchart TD
+  A[Slides component receives props] --> B{hasMarpFlag?}
+  B -->|true| C[React.lazy loads MarpSlides chunk]
+  C --> D[MarpSlides renders via marp-core]
+  B -->|false| E[GrowiSlides renders with pre-extracted CSS]
+  E --> F[No Marp modules loaded]
+```
+
+### Build-Time CSS Extraction Flow
+
+```mermaid
+flowchart TD
+  A[pnpm run build] --> B[pre:build:src script runs]
+  B --> C[extract-marpit-css.mjs executes]
+  C --> D[Instantiate Marp with config]
+  D --> E[Call slideMarpit.render and presentationMarpit.render]
+  E --> F[Write marpit-base-css.ts with CSS constants]
+  F --> G[vite build compiles all sources]
+```
+
+## Components and Interfaces
+
+| Component | Domain | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|--------|--------|--------------|------------------|-----------|
+| Slides | UI / Routing | Route between MarpSlides and GrowiSlides based on hasMarpFlag | 2.1, 2.2, 2.3 | MarpSlides (P1, dynamic), GrowiSlides (P0, static) | — |
+| GrowiSlides | UI / Rendering | Render non-Marp slides with pre-extracted CSS | 1.1, 1.2, 1.3, 1.4 | consts (P0), marpit-base-css (P0) | — |
+| marpit-base-css | Constants | Provide pre-extracted Marp theme CSS | 1.3, 3.3 | — | State |
+| consts/index.ts | Constants | Shared constants including MARP_CONTAINER_CLASS_NAME | 1.4 | — | — |
+| growi-marpit.ts | Service | Marp engine setup (unchanged, now only reached via MarpSlides) | 4.1, 4.3 | marp-core (P0, external), marpit (P0, external) | Service |
+| extract-marpit-css.mjs | Build Script | Generate marpit-base-css.ts at build time | 3.1, 3.2 | marp-core (P0, external), marpit (P0, external) | — |
+
+### UI / Routing Layer
+
+#### Slides
+
+| Field | Detail |
+|-------|--------|
+| Intent | Route rendering to MarpSlides (dynamic) or GrowiSlides (static) based on `hasMarpFlag` prop |
+| Requirements | 2.1, 2.2, 2.3 |
+
+**Responsibilities & Constraints**
+- Conditionally render MarpSlides or GrowiSlides based on `hasMarpFlag`
+- Wrap MarpSlides in `<Suspense>` with a loading fallback
+- Must not statically import MarpSlides
+
+**Dependencies**
+- Outbound: MarpSlides — dynamic import for Marp rendering (P1)
+- Outbound: GrowiSlides — static import for non-Marp rendering (P0)
+
+**Contracts**: State [x]
+
+##### State Management
+
+```typescript
+// Updated Slides component signature (unchanged externally)
+type SlidesProps = {
+  options: PresentationOptions;
+  children?: string;
+  hasMarpFlag?: boolean;
+  presentation?: boolean;
+};
+
+// Internal: MarpSlides loaded via React.lazy
+// const MarpSlides = lazy(() => import('./MarpSlides').then(mod => ({ default: mod.MarpSlides })))
+```
+
+- Persistence: None (stateless component)
+- Concurrency: Single render path per props
+
+**Implementation Notes**
+- Integration: Replace static `import { MarpSlides }` with `React.lazy` dynamic import
+- Validation: `hasMarpFlag` is optional boolean; undefined/false → GrowiSlides path
+- Risks: Suspense fallback visible during first MarpSlides load (mitigated by parent-level loading indicators)
+
+### UI / Rendering Layer
+
+#### GrowiSlides
+
+| Field | Detail |
+|-------|--------|
+| Intent | Render non-Marp slides using pre-extracted CSS instead of runtime Marp |
+| Requirements | 1.1, 1.2, 1.3, 1.4 |
+
+**Responsibilities & Constraints**
+- Render slide content via ReactMarkdown with section extraction
+- Apply Marp container CSS from pre-extracted constants (no runtime Marp dependency)
+- Use `MARP_CONTAINER_CLASS_NAME` from shared constants module
+
+**Dependencies**
+- Inbound: Slides — rendering delegation (P0)
+- Outbound: consts/index.ts — `MARP_CONTAINER_CLASS_NAME` (P0)
+- Outbound: consts/marpit-base-css — `SLIDE_MARPIT_CSS`, `PRESENTATION_MARPIT_CSS` (P0)
+
+**Implementation Notes**
+- Integration: Replace `import { ... } from '../services/growi-marpit'` with imports from `../consts` and `../consts/marpit-base-css`
+- Replace `const { css } = marpit.render('')` with `const css = presentation ? PRESENTATION_MARPIT_CSS : SLIDE_MARPIT_CSS`
+
+### Constants Layer
+
+#### marpit-base-css
+
+| Field | Detail |
+|-------|--------|
+| Intent | Provide pre-extracted Marp theme CSS as string constants |
+| Requirements | 1.3, 3.3 |
+
+**Contracts**: State [x]
+
+##### State Management
+
+```typescript
+// Generated file — do not edit manually
+// Regenerate with: node scripts/extract-marpit-css.mjs
+
+export const SLIDE_MARPIT_CSS: string;
+export const PRESENTATION_MARPIT_CSS: string;
+```
+
+- Persistence: Committed to repository; regenerated at build time
+- Consistency: Deterministic output from Marp config; changes only on `@marp-team/marp-core` version update
+
+#### consts/index.ts
+
+| Field | Detail |
+|-------|--------|
+| Intent | Shared constants for presentation package |
+| Requirements | 1.4 |
+
+**Implementation Notes**
+- Add `export const MARP_CONTAINER_CLASS_NAME = 'marpit'` (moved from `growi-marpit.ts`)
+- Existing `PresentationOptions` type export unchanged
+
+### Service Layer
+
+#### growi-marpit.ts
+
+| Field | Detail |
+|-------|--------|
+| Intent | Marp engine setup and instance creation (unchanged behavior, now only reachable via MarpSlides dynamic path) |
+| Requirements | 4.1, 4.3 |
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+// Existing exports (unchanged)
+export const MARP_CONTAINER_CLASS_NAME: string; // Re-exported from consts for backward compat
+export const slideMarpit: Marp;
+export const presentationMarpit: Marp;
+```
+
+- Preconditions: `@marp-team/marp-core` and `@marp-team/marpit` available
+- Postconditions: Marp instances configured with GROWI options
+- Invariants: Instances are singletons created at module scope
+
+**Implementation Notes**
+- Import `MARP_CONTAINER_CLASS_NAME` from `../consts` instead of defining locally
+- Re-export for backward compatibility (MarpSlides still imports from here)
+
+### Build Script
+
+#### extract-marpit-css.mjs
+
+| Field | Detail |
+|-------|--------|
+| Intent | Generate `marpit-base-css.ts` by running Marp with the same configuration as `growi-marpit.ts` |
+| Requirements | 3.1, 3.2 |
+
+**Responsibilities & Constraints**
+- Replicate the Marp configuration from `growi-marpit.ts` (container classes, inlineSVG, etc.)
+- Generate `slideMarpit.render('')` and `presentationMarpit.render('')` CSS output
+- Write TypeScript file with exported string constants
+- Must run in Node.js ESM without additional tooling (no `tsx`, no `ts-node`)
+
+**Dependencies**
+- External: `@marp-team/marp-core` — Marp rendering engine (P0)
+- External: `@marp-team/marpit` — Element class for container config (P0)
+
+**Implementation Notes**
+- Integration: Added as `"pre:build:src"` script in `package.json`; runs before `vite build`
+- Validation: Script exits with error if CSS extraction produces empty output
+- Risks: Marp options must stay synchronized with `growi-marpit.ts`
+

+ 26 - 0
.kiro/specs/presentation/requirements.md

@@ -0,0 +1,26 @@
+# Presentation Feature — Requirements Overview
+
+## Introduction
+
+The GROWI presentation feature (`@growi/presentation` package) provides slide rendering for wiki pages using frontmatter flags. It supports two rendering modes:
+
+- **GrowiSlides** (`slide: true`): Lightweight slide rendering using ReactMarkdown with Marp container styling applied via pre-extracted CSS constants. Does not load Marp runtime dependencies.
+- **MarpSlides** (`marp: true`): Full Marp-powered slide rendering using `@marp-team/marp-core`, loaded dynamically only when needed.
+
+## Key Requirements
+
+### 1. Module Separation
+
+GrowiSlides renders without loading `@marp-team/marp-core` or `@marp-team/marpit`. Marp dependencies are isolated behind a dynamic import boundary (`React.lazy`) and only loaded for pages with `marp: true`.
+
+### 2. Build-Time CSS Extraction
+
+Marp base CSS is pre-extracted at build time via `extract-marpit-css.mjs`. The generated constants file (`consts/marpit-base-css.ts`) is committed to the repository so that dev mode works without running the extraction script first. The extraction runs automatically before source compilation via the `pre:build:src` script.
+
+### 3. Functional Equivalence
+
+Both Marp and non-Marp slide pages render correctly in inline view and presentation modal. No behavioral differences from the user's perspective.
+
+### 4. Build Integrity
+
+Both `@growi/presentation` and `@growi/app` build successfully. The GrowiSlides build output contains no Marp module references, and the Slides build output contains a dynamic `import()` for MarpSlides.

+ 84 - 0
.kiro/specs/presentation/research.md

@@ -0,0 +1,84 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `optimize-presentation`
+- **Discovery Scope**: Extension
+- **Key Findings**:
+  - `GrowiSlides` imports `growi-marpit.ts` only for CSS extraction (`marpit.render('')`) and a string constant — no Marp rendering is performed
+  - `growi-marpit.ts` instantiates `new Marp(...)` at module scope, pulling in `@marp-team/marp-core` (~524KB) and `@marp-team/marpit` (~372KB) unconditionally
+  - All consumers of `Slides.tsx` are already behind SSR-disabled dynamic boundaries (`next/dynamic`, `useLazyLoader`), making `React.lazy` safe to use within the package
+
+## Research Log
+
+### GrowiSlides dependency on growi-marpit.ts
+- **Context**: Investigating why GrowiSlides requires Marp at all
+- **Sources Consulted**: `packages/presentation/src/client/components/GrowiSlides.tsx`, `packages/presentation/src/client/services/growi-marpit.ts`
+- **Findings**:
+  - `GrowiSlides` imports `MARP_CONTAINER_CLASS_NAME` (string `'marpit'`), `presentationMarpit`, `slideMarpit`
+  - Usage: `const { css } = marpit.render('');` — renders empty string to extract base theme CSS
+  - The CSS output is deterministic (same Marp config → same CSS every time)
+  - `MARP_CONTAINER_CLASS_NAME` is a plain string constant co-located with heavy Marp code
+- **Implications**: GrowiSlides can be fully decoupled by pre-extracting the CSS and moving the constant
+
+### Vite build with preserveModules and React.lazy
+- **Context**: Verifying that React.lazy dynamic import works correctly in the package's Vite build output
+- **Sources Consulted**: `packages/presentation/vite.config.ts`
+- **Findings**:
+  - Build uses `preserveModules: true` with `preserveModulesRoot: 'src'` — each source file → separate output module
+  - `nodeExternals({ devDeps: true })` externalizes `@marp-team/marp-core` and `@marp-team/marpit`
+  - Dynamic `import('./MarpSlides')` in source will produce dynamic `import('./MarpSlides.js')` in output
+  - Next.js (app bundler) handles code-splitting of the externalized Marp packages into async chunks
+- **Implications**: React.lazy in Slides.tsx will produce the correct dynamic import boundary in the build output
+
+### SSR safety of React.lazy
+- **Context**: React.lazy does not support SSR in React 18; need to verify all render paths are client-only
+- **Sources Consulted**: Consumer components in apps/app
+- **Findings**:
+  - `PagePresentationModal`: `useLazyLoader` → client-only
+  - `Presentation` wrapper: `next/dynamic({ ssr: false })`
+  - `SlideRenderer`: `next/dynamic({ ssr: false })` in PageView.tsx
+  - No SSR path exists for `Slides.tsx`
+- **Implications**: React.lazy is safe; no need for `next/dynamic` in the shared package
+
+### CSS extraction approach
+- **Context**: Evaluating how to pre-extract the Marp base CSS
+- **Sources Consulted**: `growi-marpit.ts`, package.json scripts, existing `build:vendor-styles` pattern
+- **Findings**:
+  - `slideMarpit.render('')` and `presentationMarpit.render('')` produce deterministic CSS strings
+  - CSS only changes when `@marp-team/marp-core` is upgraded or Marp options change
+  - The package already has a `build:vendor-styles` script pattern for pre-building CSS assets
+  - `tsx` is not available in the workspace; extraction script must use plain Node.js (.mjs)
+- **Implications**: An .mjs extraction script with dynamic imports from the built package or direct Marp usage is the simplest approach
+
+## Design Decisions
+
+### Decision: Pre-extracted CSS constants vs runtime generation
+- **Context**: GrowiSlides needs Marp theme CSS but should not load Marp runtime
+- **Alternatives Considered**:
+  1. Dynamic import of growi-marpit in GrowiSlides — adds async complexity for a static value
+  2. Pre-extract CSS at build time as string constants — zero runtime cost
+  3. CSS file extracted to dist — requires additional CSS import handling
+- **Selected Approach**: Pre-extract CSS as TypeScript string constants in `consts/marpit-base-css.ts`
+- **Rationale**: The CSS is deterministic; generating it at build time eliminates runtime overhead entirely. TypeScript constants integrate seamlessly with existing import patterns.
+- **Trade-offs**: Requires regeneration on Marp version upgrade (mitigated by `pre:build:src` script)
+- **Follow-up**: Verify CSS output matches runtime generation; commit generated file for dev mode
+
+### Decision: React.lazy vs next/dynamic for MarpSlides
+- **Context**: Need dynamic import boundary for MarpSlides within the shared `@growi/presentation` package
+- **Alternatives Considered**:
+  1. `next/dynamic` — Next.js-specific, couples package to framework
+  2. `React.lazy + Suspense` — standard React, works with any bundler
+- **Selected Approach**: `React.lazy + Suspense`
+- **Rationale**: Although the package already uses `next/head`, `React.lazy` is the standard React pattern for code-splitting and avoids further Next.js coupling. All consumer paths already disable SSR, so React.lazy's SSR limitation is irrelevant.
+- **Trade-offs**: Requires `<Suspense>` wrapper; fallback UI is visible during chunk load
+- **Follow-up**: Verify chunk splitting in Next.js production build
+
+### Decision: Shared constants module for MARP_CONTAINER_CLASS_NAME
+- **Context**: `MARP_CONTAINER_CLASS_NAME` is defined in `growi-marpit.ts` but needed by GrowiSlides without Marp dependency
+- **Selected Approach**: Move to `consts/index.ts` (existing shared constants module)
+- **Rationale**: The constant has no dependency on Marp; co-locating it with Marp code creates an unnecessary transitive import
+
+## Risks & Mitigations
+- **CSS drift on Marp upgrade**: Pre-extracted CSS may become stale → `pre:build:src` script auto-regenerates before every build
+- **Suspense flash on Marp load**: Brief loading indicator when MarpSlides loads → Masked by parent `next/dynamic` loading spinner in most paths
+- **Build script compatibility**: `.mjs` script must work in CI and local dev → Use standard Node.js ESM with no external tooling dependencies

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

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

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

@@ -0,0 +1,359 @@
+# Design Document
+
+## Overview
+
+**Purpose**: AI-powered path suggestion API that helps AI clients (e.g., Claude via MCP) determine optimal save locations for page content in GROWI. The system analyzes content, searches for related pages, evaluates candidates, and returns directory path suggestions with metadata.
+
+**Users**: AI clients (Claude via MCP) call this endpoint on behalf of GROWI users during the "save to GROWI" workflow.
+
+### Goals
+
+- Single POST endpoint returning path suggestions with metadata (type, path, label, description, grant)
+- Memo path: guaranteed fallback with fixed metadata
+- Search-based suggestions: AI-powered with flow/stock classification, multi-candidate evaluation, and intelligent path proposals (including new paths)
+- Independent access control via separate `ai-tools` namespace from `/page`
+
+### Design Principles
+
+- **Client LLM independence**: Heavy reasoning (content analysis, candidate evaluation, path proposal, description generation) is centralized in GROWI AI on the server side. The API response includes structured data fields (`informationType`, `type`, `grant`) alongside natural language (`description`) so that even less capable LLM clients can make correct decisions.
+
+### Non-Goals
+
+- Page creation/saving (existing `POST /_api/v3/page` handles this)
+- Page title suggestion (Claude handles this via user dialogue)
+- Client-side "enter manually" option (Agent Skill responsibility)
+
+## Architecture
+
+### Boundary Map
+
+```mermaid
+graph TB
+    subgraph Client
+        MCP[MCP Server]
+    end
+
+    subgraph GROWI_API[GROWI API]
+        Router[ai-tools Router]
+        Handler[suggest-path Handler]
+        MemoGen[Memo Suggestion]
+        Analyzer[Content Analyzer - 1st AI Call]
+        Retriever[Search Candidate Retriever]
+        Evaluator[Candidate Evaluator - 2nd AI Call]
+        CategoryGen[Category Suggestion - Under Review]
+    end
+
+    subgraph Existing[Existing Services]
+        SearchSvc[Search Service]
+        GrantSvc[Page Grant Service]
+        AIFeature[GROWI AI - OpenAI Feature]
+    end
+
+    subgraph Data
+        ES[Elasticsearch]
+        Mongo[MongoDB - Pages]
+    end
+
+    MCP -->|POST suggest-path| Router
+    Router --> Handler
+    Handler --> MemoGen
+    Handler --> Analyzer
+    Analyzer --> AIFeature
+    Handler --> Retriever
+    Retriever --> SearchSvc
+    Handler --> Evaluator
+    Evaluator --> AIFeature
+    Handler --> CategoryGen
+    CategoryGen --> SearchSvc
+    SearchSvc --> ES
+    Evaluator --> GrantSvc
+    CategoryGen --> GrantSvc
+    GrantSvc --> Mongo
+```
+
+**Integration notes**:
+
+- Layered handler following existing GROWI route conventions
+- Domain boundaries: Route layer owns the endpoint, delegates to existing services (search, grant, AI) without modifying them
+- Existing patterns preserved: Handler factory pattern, middleware chain, `res.apiv3()` response format
+
+### Code Organization
+
+All suggest-path code resides in `features/ai-tools/suggest-path/` following the project's feature-based architecture pattern.
+
+```text
+apps/app/src/features/ai-tools/
+├── server/routes/apiv3/
+│   └── index.ts                              # Aggregation router for ai-tools namespace
+└── suggest-path/
+    ├── interfaces/
+    │   └── suggest-path-types.ts              # Shared types (PathSuggestion, ContentAnalysis, etc.)
+    └── server/
+        ├── routes/apiv3/
+        │   ├── index.ts                       # Route factory, handler + middleware chain
+        │   └── index.spec.ts
+        ├── services/
+        │   ├── generate-suggestions.ts        # Orchestrator
+        │   ├── generate-memo-suggestion.ts
+        │   ├── analyze-content.ts             # AI call #1: keyword extraction + flow/stock
+        │   ├── retrieve-search-candidates.ts  # ES search with score filtering
+        │   ├── evaluate-candidates.ts         # AI call #2: candidate evaluation + path proposal
+        │   ├── call-llm-for-json.ts           # Shared LLM call utility
+        │   ├── generate-category-suggestion.ts # Under review
+        │   ├── resolve-parent-grant.ts
+        │   └── *.spec.ts                      # Co-located tests
+        └── integration-tests/
+            └── suggest-path-integration.spec.ts
+```
+
+**Key decisions**:
+
+- **No barrel export**: Consumers import directly from subpaths (following `features/openai/` convention)
+- **Aggregation router retained**: The `ai-tools` router at `features/ai-tools/server/routes/apiv3/` imports the suggest-path route factory. This allows future ai-tools features to register under the same namespace
+- **R4 (CategorySuggestionGenerator)**: Under review — may be merged into AI evaluation approach post-discussion
+
+### Implementation Paradigm
+
+All components are pure functions with immutable data. No classes — no component currently meets class adoption criteria (shared dependency management or singleton state).
+
+### Request Flow
+
+```mermaid
+sequenceDiagram
+    participant Client as MCP Client
+    participant Handler as Orchestrator
+    participant AI1 as Content Analyzer
+    participant Search as Search Service
+    participant AI2 as Candidate Evaluator
+    participant Grant as Grant Resolver
+    participant CatGen as Category Generator
+
+    Client->>Handler: POST with body content
+    Handler->>Handler: Generate memo suggestion
+
+    Handler->>AI1: Analyze content body
+    Note over AI1: 1st AI Call
+    AI1-->>Handler: keywords + informationType
+
+    par Search and evaluate
+        Handler->>Search: Search by keywords
+        Search-->>Handler: Raw results with scores
+        Handler->>Handler: Filter by score threshold
+        Handler->>AI2: body + analysis + candidates
+        Note over AI2: 2nd AI Call
+        AI2-->>Handler: Evaluated suggestions with paths and descriptions
+        loop For each evaluated suggestion
+            Handler->>Grant: Resolve grant for proposed path
+            Grant-->>Handler: Grant value
+        end
+    and Category suggestion
+        Handler->>CatGen: Generate from keywords
+        CatGen->>Search: Scoped keyword search
+        Search-->>CatGen: Top-level pages
+        CatGen->>Grant: Resolve parent grant
+        Grant-->>CatGen: Grant value
+        CatGen-->>Handler: Category suggestion or null
+    end
+
+    Handler-->>Client: 200 suggestions array
+```
+
+**Key decisions**:
+
+- Content analysis and candidate evaluation are structurally sequential — Elasticsearch sits between them
+- Search-evaluate flow and category generation run in parallel
+- If content analysis fails → memo-only response
+- If candidate evaluation fails → memo + category (if available)
+- Category generator runs independently (under review)
+
+## Component Interfaces
+
+### Orchestrator
+
+```typescript
+function generateSuggestions(
+  user: IUserHasId,
+  body: string,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
+): Promise<PathSuggestion[]>;
+```
+
+- **No DI pattern**: Imports service functions directly; only `searchService` is passed as a parameter (the sole external dependency that cannot be statically imported)
+- **Invariant**: Returns array with at least one suggestion (memo type), regardless of failures
+- **informationType mapping**: Attaches `ContentAnalysis.informationType` to each search-type suggestion (Req 13.1)
+
+### Content Analyzer (1st AI Call)
+
+```typescript
+type ContentAnalysis = {
+  keywords: string[];            // 1-5 keywords, proper nouns prioritized
+  informationType: 'flow' | 'stock';
+};
+
+function analyzeContent(body: string): Promise<ContentAnalysis>;
+```
+
+### Search Candidate Retriever
+
+```typescript
+type SearchCandidate = {
+  pagePath: string;
+  snippet: string;
+  score: number;
+};
+
+function retrieveSearchCandidates(
+  keywords: string[],
+  user: IUserHasId,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
+): Promise<SearchCandidate[]>;
+```
+
+- `searchService` is a direct positional argument (not wrapped in an options object)
+- Score threshold is a module-level constant (`SCORE_THRESHOLD = 5.0`)
+- Filters by ES score threshold; returns empty array if no results pass
+
+### Candidate Evaluator (2nd AI Call)
+
+```typescript
+type EvaluatedSuggestion = {
+  path: string;        // Proposed directory path with trailing /
+  label: string;
+  description: string; // AI-generated rationale
+};
+
+function evaluateCandidates(
+  body: string,
+  analysis: ContentAnalysis,
+  candidates: SearchCandidate[],
+): Promise<EvaluatedSuggestion[]>;
+```
+
+- Proposes paths using 3 structural patterns: (a) parent directory, (b) subdirectory, (c) sibling (may generate new paths at same hierarchy level)
+- Flow/stock alignment is a ranking factor, not a hard filter
+- Grant resolution performed by orchestrator after this returns
+
+### Category Suggestion Generator
+
+```typescript
+function generateCategorySuggestion(
+  candidates: SearchCandidate[],
+): Promise<PathSuggestion | null>;
+```
+
+- Under review — may be merged into AI evaluation approach post-discussion
+- Returns `null` when no matching top-level pages are found
+
+### Grant Resolver
+
+```typescript
+function resolveParentGrant(dirPath: string): Promise<number>;
+```
+
+- Traverses upward through ancestors for new paths (sibling pattern)
+- Returns `GRANT_OWNER` (4) as safe default if no ancestor found
+
+## Data Contracts
+
+### API Contract
+
+| Method | Endpoint | Request | Response | Errors |
+|--------|----------|---------|----------|--------|
+| POST | `/_api/v3/ai-tools/suggest-path` | `SuggestPathRequest` | `SuggestPathResponse` | 400, 401, 403, 500 |
+
+### Request / Response Types
+
+```typescript
+// Request
+interface SuggestPathRequest {
+  body: string; // Page content for analysis (required, non-empty)
+}
+
+// Response
+type SuggestionType = 'memo' | 'search' | 'category';
+type InformationType = 'flow' | 'stock';
+
+interface PathSuggestion {
+  type: SuggestionType;
+  path: string;                        // Directory path with trailing '/'
+  label: string;
+  description: string;                 // Fixed for memo, AI-generated for search
+  grant: number;                       // Parent page grant (PageGrant value)
+  informationType?: InformationType;   // Search-based only
+}
+
+interface SuggestPathResponse {
+  suggestions: PathSuggestion[];       // Always ≥1 element (memo)
+}
+```
+
+**Invariants**: `path` ends with `/`, `grant` is a valid PageGrant value (1, 2, 4, or 5)
+
+### Response Example
+
+```json
+{
+  "suggestions": [
+    {
+      "type": "memo",
+      "path": "/user/alice/memo/",
+      "label": "Save as memo",
+      "description": "Save to your personal memo area",
+      "grant": 4
+    },
+    {
+      "type": "search",
+      "path": "/tech-notes/React/state-management/",
+      "label": "Save near related pages",
+      "description": "This area contains pages about React state management. Your stock content fits well alongside this existing reference material.",
+      "grant": 1,
+      "informationType": "stock"
+    },
+    {
+      "type": "category",
+      "path": "/tech-notes/",
+      "label": "Save under category",
+      "description": "Top-level category: tech-notes",
+      "grant": 1
+    }
+  ]
+}
+```
+
+## Error Handling & Graceful Degradation
+
+### User Errors (4xx)
+
+| Error | Status | Requirement |
+|-------|--------|-------------|
+| Missing or empty `body` | 400 | 9.1 |
+| No authentication | 401 | 8.2 |
+| AI service not enabled | 403 | 1.4 |
+
+### Graceful Degradation (returns 200)
+
+| Failure | Fallback |
+|---------|----------|
+| Content analysis (1st AI call) | Memo only (skips entire search pipeline) |
+| Search service | Memo + category (if available) |
+| Candidate evaluation (2nd AI call) | Memo + category (if available) |
+| Category generation | Memo + search-based (if available) |
+
+Each component fails independently. Memo is always generated first as guaranteed fallback.
+
+## Security Considerations
+
+- **Authentication**: All requests require valid API token or login session (standard middleware)
+- **Authorization**: Search results are permission-scoped via `searchKeyword()` user/group parameters
+- **Input safety**: Content body is passed to GROWI AI, not directly to Elasticsearch — no NoSQL injection risk
+- **AI prompt injection**: System prompt and user content are separated to minimize prompt injection risk
+- **Information leakage**: Error responses use generic messages (Req 9.2)
+
+## Performance Considerations
+
+- Content analysis and candidate evaluation are sequential (ES sits between) — 2 AI roundtrips minimum
+- Search-evaluate pipeline and category generation run in parallel to minimize total latency
+- ES snippets (not full page bodies) are passed to AI to manage context budget
+- Score threshold filtering reduces the number of candidates passed to the 2nd AI call

+ 77 - 0
.kiro/specs/suggest-path/requirements.md

@@ -0,0 +1,77 @@
+# Requirements Document
+
+## Introduction
+
+The suggest-path feature provides an AI-powered API endpoint for GROWI that suggests optimal page save locations. When an AI client (e.g., Claude via MCP) sends page content, the endpoint analyzes it and returns directory path suggestions with metadata including descriptions and grant (permission) constraints.
+
+The feature was delivered in two phases:
+
+- **Phase 1 (MVP)**: Personal memo path suggestion — endpoint, authentication, and response structure.
+- **Phase 2 (Full)**: AI-powered search-based path suggestions with flow/stock information classification, multi-candidate evaluation, and intelligent path proposal (including new paths).
+
+### Phase 2 Revision History
+
+Phase 2 was revised based on reviewer feedback: (1) flow/stock information classification, (2) multi-candidate AI evaluation instead of top-1 selection, (3) three-pattern path proposals (parent/subdirectory/sibling), (4) AI-generated descriptions.
+
+## Out of Scope
+
+- **Page creation/saving**: Uses existing `POST /_api/v3/page`. This feature only suggests *where* to save.
+- **Page title determination**: Handled via AI client-user dialogue.
+
+## Requirements
+
+### Requirement 1: Path Suggestion API Endpoint
+
+**Summary**: POST endpoint at `/_api/v3/ai-tools/suggest-path` accepts a `body` field and returns an array of path suggestions. Each suggestion includes `type`, `path` (directory with trailing `/`), `label`, `description`, and `grant`. Endpoint is under a separate namespace from `/_api/v3/page/` for independent access control.
+
+### Requirement 2: Memo Path Suggestion (Phase 1)
+
+**Summary**: Always includes a `memo` type suggestion as guaranteed fallback. Path is `/user/{username}/memo/` when user pages are enabled, or `/memo/{username}/` when disabled. Grant is `4` (owner only). Description is fixed text.
+
+### Requirement 3: Search-Based Path Suggestion (Phase 2)
+
+**Summary**: Searches for related pages using extracted keywords, filters by Elasticsearch score threshold, then passes all passing candidates to AI-based evaluation (Req 11). Includes parent page's grant. Omitted if no candidates pass the threshold.
+
+### Requirement 4: Category-Based Path Suggestion (Phase 2) — Under Review
+
+**Summary**: Extracts top-level path segment from keyword-matched pages as a `category` type suggestion. Includes parent grant. Omitted if no match found.
+
+> **Note**: May overlap with the AI-based evaluation approach (Reqs 11, 12). Whether to retain, merge, or remove will be determined after reviewer discussion.
+
+### Requirement 5: Content Analysis via GROWI AI (Phase 2)
+
+**Summary**: Single AI call performs keyword extraction (1-5 keywords, proper nouns prioritized) and flow/stock information type classification. Keywords (not raw content) are used for search. On failure, falls back to memo-only response.
+
+### Requirement 6: Suggestion Description Generation
+
+**Summary**: Each suggestion includes a `description` field. Memo uses fixed text. Search-based suggestions use AI-generated descriptions from candidate evaluation (Req 11).
+
+### Requirement 7: Grant Constraint Information
+
+**Summary**: Each suggestion includes a `grant` field representing the parent page's grant value — the upper bound of settable permissions for child pages (a constraint, not a recommendation).
+
+### Requirement 8: Authentication and Authorization
+
+**Summary**: Requires valid API token or login session. Returns authentication error if missing. Uses authenticated user's identity for user-specific suggestions.
+
+### Requirement 9: Input Validation and Error Handling
+
+**Summary**: Returns validation error for missing/empty `body`. Internal errors return appropriate responses without exposing system details.
+
+### Requirement 10: Flow/Stock Information Type Awareness (Phase 2)
+
+**Summary**: Candidate evaluation considers flow/stock alignment between content and candidate locations. Flow = time-bound (date-based paths, meeting terms). Stock = reference (topic-based paths). Used as a ranking factor, not a hard filter.
+
+### Requirement 11: AI-Based Candidate Evaluation and Ranking (Phase 2)
+
+**Summary**: GROWI AI evaluates each candidate's suitability using content body, candidate path, and snippet. Ranks by content-destination fit considering relevance and flow/stock alignment. Generates description per suggestion. Falls back to memo-only on failure.
+
+### Requirement 12: Path Proposal Patterns (Phase 2)
+
+**Summary**: Three structural patterns relative to each matching page: (a) parent directory, (b) subdirectory, (c) sibling directory. Sibling pattern generates new directory names at the same hierarchy level as the candidate. AI determines the most appropriate pattern.
+
+### Requirement 13: Client LLM Independence (Phase 2)
+
+**Summary**: Response includes both structured metadata (`informationType`, `type`, `grant`) and natural language (`description`) so any LLM client can use it regardless of reasoning capability. All reasoning-intensive operations are server-side.
+
+**Design Rationale**: MCP clients are powered by varying LLM models. Heavy reasoning is centralized in GROWI AI to prevent quality degradation with less capable clients.

+ 145 - 0
.kiro/specs/suggest-path/research.md

@@ -0,0 +1,145 @@
+# Research & Design Decisions
+
+## Summary
+
+- **Feature**: `suggest-path`
+- **Discovery Scope**: Extension (new endpoint added to existing API infrastructure)
+- **Key Findings**:
+  - GROWI uses a handler factory pattern (`(crowi: Crowi) => RequestHandler[]`) for API routes
+  - The `ai-tools` namespace does not exist yet; closest is `/openai` under `features/openai/`
+  - Grant parent-child constraints are enforced by `page-grant.ts` — GRANT_OWNER children must share the same owner
+  - `searchService.searchKeyword()` accepts keyword string and returns scored results with page metadata
+  - User home path utilities exist in `@growi/core` (`userHomepagePath`, `isUsersHomepage`)
+
+## Research Log
+
+### GROWI API Route Patterns
+
+- **Context**: Need to understand how to add a new route namespace
+- **Sources Consulted**: `apps/app/src/server/routes/apiv3/index.js`, `page/create-page.ts`, `features/openai/server/routes/index.ts`
+- **Findings**:
+  - Three router types: standard, admin, auth. New endpoints go on standard router
+  - Route registration: `router.use('/namespace', require('./namespace')(crowi))` or factory import
+  - Handler factory pattern: exports `(crowi: Crowi) => RequestHandler[]` returning middleware chain
+  - Middleware ordering: `accessTokenParser` → `loginRequiredStrictly` → validators → `apiV3FormValidator` → handler
+  - Response helpers: `res.apiv3(data)` for success, `res.apiv3Err(error, status)` for errors
+  - Feature-based routes use dynamic import pattern (see openai routes)
+- **Implications**: suggest-path follows the handler factory pattern. Route factory in `features/ai-tools/suggest-path/server/routes/apiv3/`, aggregation router in `features/ai-tools/server/routes/apiv3/`
+
+### OpenAI Feature Structure
+
+- **Context**: Understanding existing AI feature patterns for alignment
+- **Sources Consulted**: `features/openai/server/routes/index.ts`, `middlewares/certify-ai-service.ts`
+- **Findings**:
+  - AI routes gate on `aiEnabled` config via `certifyAiService` middleware
+  - Dynamic imports used for route handlers
+  - Dedicated middleware directory for AI-specific checks
+  - Routes organized under `features/openai/` not `routes/apiv3/`
+- **Implications**: suggest-path gates on AI-enabled config via `certifyAiService`. Code lives under `features/ai-tools/suggest-path/` with an aggregation router at `features/ai-tools/server/routes/apiv3/`.
+
+### Grant System Constraints
+
+- **Context**: Need to return accurate grant constraints for suggested paths
+- **Sources Consulted**: `@growi/core` PageGrant enum, `apps/app/src/server/service/page-grant.ts`
+- **Findings**:
+  - PageGrant values: PUBLIC(1), RESTRICTED(2), SPECIFIED(3-deprecated), OWNER(4), USER_GROUP(5)
+  - Parent constrains child: OWNER parent → child must be OWNER by same user; USER_GROUP parent → child cannot be PUBLIC
+  - `calcApplicableGrantData(page, user)` returns allowed grant types for a page
+  - For memo path (`/user/{username}/memo/`), the user homepage `/user/{username}` is GRANT_OWNER(4) by default → memo path grant is fixed at 4
+- **Implications**: Phase 1 memo grant is trivially 4. Phase 2 needs to look up actual parent page grant via Page model
+
+### Search Service Integration
+
+- **Context**: Phase 2 requires keyword-based search for related pages
+- **Sources Consulted**: `apps/app/src/server/service/search.ts`
+- **Findings**:
+  - `searchKeyword(keyword, nqName, user, userGroups, searchOpts)` → `[ISearchResult, delegatorName]`
+  - Results include `_id`, `_score`, `_source`, `_highlight`
+  - Supports `prefix:` queries for path-scoped search
+  - User groups needed for permission-scoped search results
+- **Implications**: Phase 2 uses `searchKeyword` with extracted keywords. Category search uses `prefix:/` to scope to top-level. Need `getUserRelatedGroups()` for permission-correct results.
+
+### User Home Path Utilities
+
+- **Context**: Memo path generation needs user home path
+- **Sources Consulted**: `@growi/core` `page-path-utils/index.ts`
+- **Findings**:
+  - `userHomepagePath(user)` → `/user/{username}`
+  - `isUsersHomepage(path)` → boolean check
+  - `getUsernameByPath(path)` → extract username from path
+- **Implications**: Use `userHomepagePath(req.user)` + `/memo/` for memo suggestion path
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Route under `features/ai-tools/` | Feature-based directory with aggregation router | Clean separation, follows features pattern and `ai-tools` naming | — | **Selected** — aligns with project architecture and independent access control |
+| Route under `features/openai/` | Extend existing AI feature module | Reuses AI infrastructure, minimal setup | Provider-specific name, harder to separate for independent access control | Rejected in review — namespace should be provider-agnostic |
+| Route under `routes/apiv3/page/` | Add to existing page routes | Close to page creation | Cannot gate independently for access control | Rejected in review — yuki requested separation |
+
+## Design Decisions
+
+### Decision: Route Namespace Placement
+
+- **Context**: Endpoint needs independent access control
+- **Alternatives Considered**:
+  1. `/openai/suggest-path` — groups with AI features but provider-specific
+  2. `/page/suggest-path` — close to page creation but cannot gate independently
+  3. `/ai-tools/suggest-path` — new provider-agnostic namespace
+- **Selected Approach**: `/_api/v3/ai-tools/suggest-path` under `features/ai-tools/suggest-path/`
+- **Rationale**: Provider-agnostic, enables independent access control, follows features directory pattern
+- **Trade-offs**: Aggregation router at `features/ai-tools/server/routes/apiv3/` allows future ai-tools features under the same namespace
+
+### Decision: Phase 1 Handler Simplicity
+
+- **Context**: Phase 1 (MVP) only returns memo path — very simple logic
+- **Alternatives Considered**:
+  1. Full service layer from the start (SuggestionService class)
+  2. Inline logic in handler, extract to service when Phase 2 arrives
+- **Selected Approach**: Inline logic in handler for Phase 1, extract to service for Phase 2
+- **Rationale**: Avoid over-engineering. Phase 1 is ~10 lines of logic. Service abstraction added when needed
+- **Trade-offs**: Phase 2 will require refactoring handler → service extraction
+- **Follow-up**: Define service interface in design for Phase 2 readiness
+
+### Decision: GROWI AI Keyword Extraction Approach
+
+- **Context**: Phase 2 needs keyword extraction from content body
+- **Alternatives Considered**:
+  1. New dedicated keyword extraction service
+  2. Extend existing OpenAI feature module
+  3. Client-side keyword extraction (fallback option)
+- **Selected Approach**: Leverage existing `features/openai/` infrastructure for keyword extraction
+- **Rationale**: GROWI already has OpenAI integration. Keyword extraction is a new capability within the existing AI feature
+- **Trade-offs**: Couples suggest-path to OpenAI feature availability. Mitigated by fallback to memo-only response
+- **Follow-up**: Detailed keyword extraction implementation is out of scope for this spec (separate design)
+
+## Risks & Mitigations
+
+- **Large content body performance**: Sending full content for AI keyword extraction may be slow. Mitigation: fallback to memo-only if extraction fails
+- **Search service dependency**: Depends on Elasticsearch being available. Mitigation: graceful degradation — return memo suggestion if search fails
+
+## Post-Implementation Discoveries
+
+### Lesson: Avoid Testability-Motivated DI in Feature Services
+
+- **Context**: Initial Phase 2 implementation used a `GenerateSuggestionsDeps` pattern — a `deps` parameter containing 5 callback functions injected into the orchestrator for testability
+- **Problem**: The pattern was inconsistent with the rest of the codebase (other modules use `vi.mock()` for testing), added route handler boilerplate (10 lines wiring callbacks), and forced unnecessary abstractions like `RetrieveSearchCandidatesOptions`
+- **Resolution**: Removed `deps` pattern; service functions are imported directly. Only `searchService` is passed as a parameter (the sole external dependency that cannot be statically imported). Tests use `vi.mock()` — consistent with `generate-memo-suggestion` and other modules
+- **Guideline**: In this codebase, prefer `vi.mock()` over DI patterns for feature-specific service layers. Reserve DI for true cross-cutting concerns or when the dependency is a runtime-varying service instance (like `searchService`)
+
+### Lesson: Type Propagation from Legacy Code
+
+- **Context**: `searchService.searchKeyword()` in `src/server/service/search.ts` has untyped parameters (legacy JS-to-TS migration), so the suggest-path code initially used `userGroups: unknown` as a safe catch-all
+- **Resolution**: Traced the actual type from `findAllUserGroupIdsRelatedToUser()` which returns `ObjectIdLike[]` (from `@growi/core`), and propagated it through the `SearchService` interface and all service functions
+- **Guideline**: When integrating with legacy untyped services, trace the actual runtime type from the call site rather than defaulting to `unknown`
+
+## References
+
+- [GROWI Search Internals](https://dev.growi.org/69842ea0cb3a20a69b0a1985) — Search feature internal architecture
+- `apps/app/src/server/routes/apiv3/index.js` — Route registration entry point
+- `apps/app/src/server/routes/apiv3/page/create-page.ts` — Reference handler pattern
+- `apps/app/src/features/openai/server/routes/index.ts` — AI feature route pattern
+- `packages/core/src/interfaces/page.ts` — PageGrant enum definition
+- `apps/app/src/server/service/page-grant.ts` — Grant validation logic
+- `apps/app/src/server/service/search.ts` — Search service interface
+- `packages/core/src/utils/page-path-utils/index.ts` — User path utilities

+ 23 - 0
.kiro/specs/suggest-path/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "suggest-path",
+  "created_at": "2026-02-10T12:00:00Z",
+  "updated_at": "2026-03-23T00:00:00Z",
+  "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
+}

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

@@ -0,0 +1,82 @@
+# Implementation Plan
+
+## Phase 1 (MVP) — Implemented
+
+- [x] 1. Phase 1 MVP — Shared types, memo path suggestion, and endpoint registration
+- [x] 1.1 Define suggestion types and implement memo path generation
+- [x] 1.2 Register route endpoint with authentication and validation
+- [x] 1.3 Phase 1 integration verification
+
+## Phase 2 — Revised
+
+- [x] 2. (P) Enhance grant resolver for ancestor path traversal
+- [x] 3. (P) Content analysis via GROWI AI (1st AI call)
+- [x] 4. (P) Search candidate retrieval with score threshold filtering
+- [x] 5. (P) AI-based candidate evaluation and path proposal (2nd AI call)
+- [x] 6. (P) Category-based path suggestion (under review — prior implementation retained)
+- [x] 7. Phase 2 revised orchestration and integration
+- [x] 7.1 Rewrite orchestration for revised Phase 2 pipeline
+- [x] 7.2 Phase 2 integration verification
+
+## Post-Implementation Refactoring (from code review)
+
+See `gap-analysis.md` for detailed rationale.
+
+- [x] 8. Simplify service layer abstractions
+- [x] 8.1 Remove `GenerateSuggestionsDeps` DI pattern from `generate-suggestions.ts`
+- [x] 8.2 Remove `RetrieveSearchCandidatesOptions` from `retrieve-search-candidates.ts`
+- [x] 8.3 Add JSDoc to `call-llm-for-json.ts`
+- [x] 8.4 Narrow `userGroups: unknown` to `ObjectIdLike[]`
+
+## Requirements Coverage
+
+| Requirement | Task(s) |
+|-------------|---------|
+| 1.1 | 1.2, 1.3, 7.1 |
+| 1.2 | 1.1, 1.3, 7.1 |
+| 1.3 | 1.1, 1.3, 7.1 |
+| 1.4 | 1.2, 1.3 |
+| 2.1 | 1.1, 1.3 |
+| 2.2 | 1.1 |
+| 2.3 | 1.1 |
+| 2.4 | 1.1 |
+| 2.5 | 1.1 |
+| 3.1 | 4, 7.2 |
+| 3.2 | 4, 7.2 |
+| 3.3 | 5, 7.1, 7.2 |
+| 3.4 | 7.1, 7.2 |
+| 3.5 | 4, 7.2 |
+| 4.1 | 6 |
+| 4.2 | 6 |
+| 4.3 | 6 |
+| 4.4 | 6 |
+| 5.1 | 3, 7.2 |
+| 5.2 | 3 |
+| 5.3 | 4, 7.1 |
+| 5.4 | 3, 7.2 |
+| 5.5 | 7.1, 7.2 |
+| 6.1 | 1.1, 7.2 |
+| 6.2 | 1.1 |
+| 6.3 | 5, 7.2 |
+| 7.1 | 2 |
+| 7.2 | 2 |
+| 8.1 | 1.2, 1.3 |
+| 8.2 | 1.2, 1.3 |
+| 8.3 | 1.2, 7.1 |
+| 9.1 | 1.2, 1.3 |
+| 9.2 | 1.2, 7.1 |
+| 10.1 | 5, 7.2 |
+| 10.2 | 5 |
+| 10.3 | 5 |
+| 10.4 | 5 |
+| 11.1 | 5, 7.2 |
+| 11.2 | 5 |
+| 11.3 | 5 |
+| 11.4 | 7.1, 7.2 |
+| 12.1 | 5, 7.2 |
+| 12.2 | 5 |
+| 12.3 | 5 |
+| 12.4 | 5 |
+| 13.1 | 7.1, 7.2 |
+| 13.2 | 7.1, 7.2 |
+| 13.3 | 7.1 |

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

@@ -4,5 +4,17 @@ See: `.claude/skills/monorepo-overview/SKILL.md` (auto-loaded by Claude Code)
 
 ## 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`.
+
+### The positioning of @growi/core.
+
+See: `.claude/skills/monorepo-overview/SKILL.md` — "@growi/core — Domain & Utilities Hub" section
+
+---
+_Updated: 2026-03-24. @growi/core details moved to monorepo-overview SKILL.md (auto-loaded)._

+ 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
 
-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:
 
 ```bash
-pnpm run lint:typecheck
-pnpm run lint:biome
+pnpm run lint
 pnpm run test
 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
 
-- Node.js v18.x or v20.x
+- Node.js v24.x
 - npm 6.x
 - pnpm 9.x
 - [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
 - pnpm 9.x
 - [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 (see `fix-broken-next-symlinks` skill) |
+| 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
 # Development mode
-pnpm run dev:pre:styles
+pnpm run dev:pre:styles-commons
+pnpm run dev:pre:styles-components
 
 # 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.
@@ -140,6 +142,18 @@ pnpm run version:prerelease
 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
 
 ```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.

+ 90 - 0
apps/app/.claude/skills/learned/fix-broken-next-symlinks/SKILL.md

@@ -0,0 +1,90 @@
+---
+name: fix-broken-next-symlinks
+description: Fix broken symlinks in .next/node_modules/ — diagnose, decide allowlist vs dependencies, and verify
+---
+
+## IMPORTANT
+
+This document is a **mandatory step-by-step procedure**. When fixing broken symlinks, execute every step in order. In particular, verification **always** requires the full 3-command sequence: `build` → `assemble-prod.sh` → `check-next-symlinks.sh`. Never skip `assemble-prod.sh` — the symlink check is only meaningful after production assembly.
+
+## Problem
+
+Turbopack externalizes packages into `.next/node_modules/` as symlinks, even for packages imported only via dynamic `import()` inside `useEffect`. After `assemble-prod.sh` runs `pnpm deploy --prod`, `devDependencies` are excluded, breaking those symlinks. `check-next-symlinks.sh` detects these and fails the build.
+
+## Diagnosis
+
+### Step 1 — Reproduce locally
+
+```bash
+turbo run build --filter @growi/app
+bash apps/app/bin/assemble-prod.sh
+bash apps/app/bin/check-next-symlinks.sh
+```
+
+If the check reports `BROKEN: apps/app/.next/node_modules/<package>-<hash>`, proceed to Step 2.
+
+### Step 2 — Determine the fix
+
+Search all import sites of the broken package:
+
+```bash
+grep -rn "from ['\"]<package-name>['\"]" apps/app/src/
+grep -rn "import(['\"]<package-name>['\"])" apps/app/src/
+```
+
+Apply the decision tree:
+
+```
+Is the package imported ONLY via:
+  - `import type { ... } from 'pkg'`  (erased at compile time)
+  - `await import('pkg')` inside useEffect / event handler  (client-side only, never SSR)
+
+  YES → Add to ALLOWED_BROKEN in check-next-symlinks.sh  (Step 3a)
+  NO  → Move from devDependencies to dependencies          (Step 3b)
+```
+
+### Step 3a — Add to allowlist
+
+Edit `apps/app/bin/check-next-symlinks.sh`:
+
+```bash
+ALLOWED_BROKEN=(
+  fslightbox-react
+  @emoji-mart/data
+  @emoji-mart/react
+  socket.io-client
+  <new-package>          # <-- add here
+)
+```
+
+Use the bare package name (e.g., `socket.io-client`), not the hashed symlink name (`socket.io-client-46e5ba4d4c848156`).
+
+### Step 3b — Move to dependencies
+
+In `apps/app/package.json`, move the package from `devDependencies` to `dependencies`, then run `pnpm install`.
+
+### Step 4 — Verify the fix
+
+Re-run the full sequence:
+
+```bash
+turbo run build --filter @growi/app
+bash apps/app/bin/assemble-prod.sh
+bash apps/app/bin/check-next-symlinks.sh
+```
+
+Expected output: `OK: All apps/app/.next/node_modules symlinks resolve correctly.`
+
+## Example
+
+`socket.io-client` is used in two files:
+- `src/states/socket-io/global-socket.ts` — `import type` + `await import()` inside `useEffect`
+- `src/features/admin/states/socket-io.ts` — `import type` + `import()` inside `useEffect`
+
+Both are client-only dynamic imports → added to `ALLOWED_BROKEN`, stays as `devDependencies`.
+
+## When to Apply
+
+- CI fails at "Check for broken symlinks in .next/node_modules" step
+- `check-next-symlinks.sh` reports `BROKEN: apps/app/.next/node_modules/<package>-<hash>`
+- After adding a new package or changing import patterns in apps/app

+ 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.ts` 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.ts`
+4. **Component import**: imports the `.prebuilt.ts` 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.ts` 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.ts, 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/
 
+NEXT_TELEMETRY_DISABLED=1
+
 APP_SITE_URL=http://localhost:3000
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760

+ 4 - 0
apps/app/.gitignore

@@ -1,6 +1,8 @@
 # next.js
 /.next/
 /out/
+next-env.d.ts
+next.config.js
 
 # test
 .reg
@@ -9,10 +11,12 @@
 /build/
 /dist/
 /transpiled/
+/public/static/fonts
 /public/static/js
 /public/static/styles
 /public/uploads
 /src/styles/prebuilt
+/src/**/*.vendor-styles.prebuilt.*
 /tmp/
 
 # 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/`.
+
+## 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."

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

@@ -0,0 +1,44 @@
+#!/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
+  socket.io-client
+)
+
+# 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 ""
+  echo "Each broken package must be either:"
+  echo "  1. Moved from devDependencies to dependencies in apps/app/package.json"
+  echo "  2. Added to ALLOWED_BROKEN in this script (if only used via useEffect + dynamic import)"
+  echo ""
+  echo "Fix: Follow the step-by-step procedure in apps/app/.claude/skills/learned/fix-broken-next-symlinks/SKILL.md"
+  echo "     You MUST execute every step in order — do NOT skip assemble-prod.sh when verifying."
+  echo "Ref: apps/app/.claude/rules/package-dependencies.md"
+  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 ""

+ 4 - 0
apps/app/bin/openapi/definition-apiv3.js

@@ -116,6 +116,10 @@ module.exports = {
         'Install',
       ],
     },
+    {
+      name: 'AI API',
+      tags: ['AI Tools'],
+    },
     {
       name: 'Public API',
       tags: ['Healthcheck', 'Statistics', '', '', '', '', '', ''],

+ 1 - 0
apps/app/bin/openapi/generate-spec-apiv3.sh

@@ -12,6 +12,7 @@ swagger-jsdoc \
   -d "${APP_PATH}/bin/openapi/definition-apiv3.js" \
   "${APP_PATH}/src/features/external-user-group/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/features/templates/server/routes/apiv3/*.ts" \
+  "${APP_PATH}/src/features/ai-tools/**/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/features/growi-plugin/server/routes/apiv3/**/*.ts" \
   "${APP_PATH}/src/server/routes/apiv3/**/*.{js,ts}" \
   "${APP_PATH}/src/server/routes/login.js" \

+ 31 - 0
apps/app/bin/postbuild-server.ts

@@ -0,0 +1,31 @@
+/**
+ * Post-build script for server compilation.
+ *
+ * tspc compiles both `src/` and `config/` (which will be migrated to TypeScript),
+ * so the output directory (`transpiled/`) mirrors the source tree structure
+ * (e.g. `transpiled/src/`, `transpiled/config/`).
+ *
+ * Setting `rootDir: "src"` and `outDir: "dist"` in tsconfig would eliminate this script,
+ * but that would break once `config/` is included in the compilation.
+ * Instead, this script extracts only `transpiled/src/` into `dist/` and discards the rest.
+ */
+import { readdirSync, renameSync, rmSync } from 'node:fs';
+
+const TRANSPILED_DIR = 'transpiled';
+const DIST_DIR = 'dist';
+const SRC_SUBDIR = `${TRANSPILED_DIR}/src`;
+
+// List transpiled contents for debugging
+// biome-ignore lint/suspicious/noConsole: This is a build script, console output is expected.
+console.log('Listing files under transpiled:');
+// biome-ignore lint/suspicious/noConsole: This is a build script, console output is expected.
+console.log(readdirSync(TRANSPILED_DIR).join('\n'));
+
+// Remove old dist
+rmSync(DIST_DIR, { recursive: true, force: true });
+
+// Move transpiled/src -> dist
+renameSync(SRC_SUBDIR, DIST_DIR);
+
+// Remove leftover transpiled directory
+rmSync(TRANSPILED_DIR, { recursive: true, force: true });

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

@@ -1,5 +1,4 @@
 const isDev = process.env.NODE_ENV === 'development';
-
 // biome-ignore lint/style/useNodejsImportProtocol: ignore
 const path = require('path');
 
@@ -8,8 +7,6 @@ const { isServer } = require('@growi/core/dist/utils');
 
 const { defaultLang } = require('./i18next.config');
 
-const HMRPlugin = isDev ? require('i18next-hmr/plugin').HMRPlugin : undefined;
-
 /** @type {import('next-i18next').UserConfig} */
 module.exports = {
   ...require('./i18next.config').initOptions,
@@ -24,11 +21,8 @@ module.exports = {
 
   use: isDev
     ? isServer()
-      ? [new HMRPlugin({ webpack: { server: true } })]
-      : [
-          require('i18next-chained-backend').default,
-          new HMRPlugin({ webpack: { client: true } }),
-        ]
+      ? []
+      : [require('i18next-chained-backend').default]
     : [],
   backend: {
     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 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 PNPM_HOME
 
 WORKDIR $OPT_DIR
 
-# install tools
+# Install build dependencies
 RUN --mount=type=cache,target=/var/lib/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 PATH="$PNPM_HOME:$PATH"
 
-# install turbo
+# Install turbo globally
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
   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 PATH="$PNPM_HOME:$PATH"
 
 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 \
   pnpm add node-gyp --global
 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 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
 
 ENV NODE_ENV="production"
-
 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}
-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
 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
 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
-**/coverage
-**/Dockerfile
-**/*.dockerignore
-**/.pnpm-store
 **/.next
 **/.turbo
+**/.pnpm-store
+**/coverage
 out
+
+# ============================================================
+# Version control
+# ============================================================
+.git
+
+# ============================================================
+# Docker files (prevent recursive inclusion)
+# ============================================================
+**/Dockerfile
+**/*.dockerignore
+
+# ============================================================
+# Unrelated apps
+# ============================================================
 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)
 
+#### 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
 ------

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

@@ -12,6 +12,8 @@ phases:
     commands:
       # login to docker.io
       - 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:
     commands:
       - 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'],
+};

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

@@ -0,0 +1,146 @@
+/**
+ * == 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',
+    '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;

+ 60 - 52
apps/app/package.json

@@ -1,24 +1,26 @@
 {
   "name": "@growi/app",
-  "version": "7.4.7",
+  "version": "7.5.0-RC.0",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "scripts": {
     "//// for production": "",
     "build": "run-p build:*",
     "start": "next start",
     "build:client": "next build",
     "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",
-    "clean": "shx rm -rf dist transpiled",
+    "postbuild:server": "node bin/postbuild-server.ts",
+    "clean": "rimraf dist transpiled .next next.config.js",
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "pnpm run server --ci",
     "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",
     "//// for development": "",
     "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": "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",
@@ -32,6 +34,7 @@
     "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": "run-p lint:**",
+    "prelint:typecheck": "next typegen",
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
     "test": "vitest run",
@@ -53,20 +56,25 @@
     "version:premajor": "pnpm version premajor --preid=RC"
   },
   "// comments for dependencies": {
-    "@aws-skd/*": "fix version above 3.186.0 that is required by mongodb@4.16.0",
-    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
-    "escape-string-regexp": "5.0.0 or above exports only ESM",
-    "next-themes": "0.3.0 causes a type error: https://github.com/pacocoursey/next-themes/issues/122",
-    "string-width": "5.0.0 or above exports only ESM."
+    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM. API breaking changes require separate migration effort."
   },
   "dependencies": {
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
-    "@aws-sdk/client-s3": "3.454.0",
-    "@aws-sdk/s3-request-presigner": "3.454.0",
+    "@aws-sdk/client-s3": "^3.1014.0",
+    "@aws-sdk/lib-storage": "^3.1014.0",
+    "@aws-sdk/s3-request-presigner": "^3.1014.0",
     "@azure/identity": "^4.4.1",
     "@azure/openai": "^2.0.0",
     "@azure/storage-blob": "^12.16.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",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.4",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.18.2",
@@ -74,6 +82,7 @@
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "workspace:^",
+    "@growi/emoji-mart-data": "workspace:^",
     "@growi/pdf-converter-client": "workspace:^",
     "@growi/pluginkit": "workspace:^",
     "@growi/presentation": "workspace:^",
@@ -84,7 +93,13 @@
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-lsx": "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",
+    "@lezer/highlight": "^1.2.3",
+    "@marp-team/marp-core": "^3.9.1",
+    "@marp-team/marpit": "^2.6.1",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/auto-instrumentations-node": "^0.60.1",
     "@opentelemetry/exporter-metrics-otlp-grpc": "^0.202.0",
@@ -94,8 +109,12 @@
     "@opentelemetry/sdk-node": "^0.202.0",
     "@opentelemetry/sdk-trace-node": "^2.0.1",
     "@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/webhook": "^6.0.0",
+    "@tanstack/react-virtual": "^3.13.12",
     "@types/async": "^3.2.24",
     "@types/multer": "^1.4.12",
     "JSONStream": "^1.3.5",
@@ -106,9 +125,12 @@
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
+    "bootstrap": "^5.3.8",
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
     "bunyan": "^1.8.15",
+    "cm6-theme-basic-light": "^0.2.0",
+    "codemirror": "^6.0.1",
     "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",
@@ -121,10 +143,11 @@
     "dayjs": "^1.11.7",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
+    "diff2html": "^3.4.47",
     "diff_match_patch": "^0.1.1",
     "dotenv-flow": "^3.2.0",
+    "downshift": "^8.2.3",
     "ejs": "^3.1.10",
-    "escape-string-regexp": "^4.0.0",
     "expose-gc": "^1.0.0",
     "express": "^4.20.0",
     "express-bunyan-logger": "^1.3.3",
@@ -132,6 +155,7 @@
     "express-session": "^1.16.1",
     "express-validator": "^6.14.0",
     "extensible-custom-error": "^0.0.7",
+    "fastest-levenshtein": "^1.0.16",
     "form-data": "^4.0.4",
     "graceful-fs": "^4.1.11",
     "hast-util-sanitize": "^5.0.1",
@@ -140,6 +164,8 @@
     "helmet": "^4.6.0",
     "http-errors": "^2.0.0",
     "i18next": "^23.16.5",
+    "i18next-http-backend": "^2.6.2",
+    "i18next-localstorage-backend": "^4.2.0",
     "i18next-resources-to-backend": "^1.2.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
@@ -167,14 +193,14 @@
     "mongoose-gridfs": "^1.3.0",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
+    "motion": "^12.35.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.35",
+    "next": "^16.2.1",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
-    "next-superjson": "^1.0.7",
-    "next-themes": "^0.2.1",
+    "next-themes": "^0.4.6",
     "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
     "nodemailer": "^6.9.15",
@@ -182,6 +208,7 @@
     "openai": "^4.96.2",
     "openid-client": "^5.4.0",
     "p-retry": "^4.0.0",
+    "pako": "^2.1.0",
     "passport": "^0.6.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
@@ -189,23 +216,30 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "pathe": "^2.0.3",
+    "pretty-bytes": "^6.1.1",
     "prop-types": "^15.8.1",
     "qs": "^6.14.2",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",
     "react-card-flip": "^1.0.10",
+    "react-copy-to-clipboard": "^5.0.1",
     "react-datepicker": "^4.7.0",
     "react-disable": "^0.1.1",
+    "react-dnd": "^14.0.5",
+    "react-dnd-html5-backend": "^14.1.0",
     "react-dom": "^18.2.0",
+    "react-dropzone": "^14.2.3",
     "react-error-boundary": "^3.1.4",
+    "react-hook-form": "^7.45.4",
     "react-i18next": "^15.1.1",
     "react-image-crop": "^8.3.0",
+    "react-input-autosize": "^3.0.0",
     "react-markdown": "^9.0.1",
     "react-multiline-clamp": "^2.0.0",
-    "react-scroll": "^1.8.7",
     "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",
     "reactstrap": "^9.2.2",
     "reconnecting-websocket": "^4.4.0",
@@ -226,9 +260,11 @@
     "remark-parse": "^11.0.0",
     "remark-rehype": "^11.1.1",
     "remark-stringify": "^11.0.0",
+    "reveal.js": "^4.4.8",
     "sanitize-filename": "^1.6.3",
+    "simplebar-react": "^2.3.6",
     "socket.io": "^4.7.5",
-    "string-width": "=4.2.2",
+    "string-width": "^7.0.0",
     "superjson": "^2.2.2",
     "swagger-jsdoc": "^6.2.8",
     "swr": "^2.3.2",
@@ -248,31 +284,23 @@
     "validator": "^13.15.22",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
+    "y-codemirror.next": "^0.3.5",
     "y-mongodb-provider": "^0.2.0",
-    "y-socket.io": "^1.1.3",
+    "y-websocket": "^2.0.4",
     "yjs": "^13.6.18",
     "zod": "^3.24.2"
   },
   "// comments for defDependencies": {
-    "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
-    "handsontable": "v7.0.0 or above is no loger MIT lisence.",
-    "mongodb": "mongoose which is used requires mongo@4.16.0."
+    "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
     "@apidevtools/swagger-parser": "^10.1.1",
-    "@codemirror/state": "^6.5.2",
-    "@emoji-mart/data": "^1.2.1",
     "@growi/core-styles": "workspace:^",
     "@growi/custom-icons": "workspace:^",
     "@growi/editor": "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",
-    "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
@@ -287,7 +315,6 @@
     "@types/react": "^18.2.14",
     "@types/react-dom": "^18.2.6",
     "@types/react-input-autosize": "^2.2.4",
-    "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
     "@types/supertest": "^6.0.3",
     "@types/testing-library__dom": "^7.5.0",
@@ -297,21 +324,13 @@
     "@types/url-join": "^4.0.2",
     "@types/uuid": "^10.0.0",
     "@types/ws": "^8.18.1",
-    "babel-loader": "^8.2.5",
-    "bootstrap": "=5.3.2",
     "commander": "^14.0.0",
     "connect-browser-sync": "^2.1.0",
-    "diff2html": "^3.4.47",
-    "downshift": "^8.2.3",
     "eazy-logger": "^3.1.0",
-    "fastest-levenshtein": "^1.0.16",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
     "happy-dom": "^15.7.4",
     "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",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
@@ -320,25 +339,14 @@
     "mongodb-connection-string-url": "^7.0.0",
     "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
-    "null-loader": "^4.0.1",
     "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",
     "remark-github-admonitions-to-directives": "^2.0.0",
     "sass": "^1.53.0",
-    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
-    "source-map-loader": "^4.0.1",
     "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
+    "tinykeys": "^3.0.0",
     "unist-util-is": "^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',
 
   webServer: {
-    command: 'pnpm run server',
+    command: process.env.GROWI_WEBSERVER_COMMAND ?? 'pnpm run server',
     url: 'http://localhost:3000',
     reuseExistingServer: !process.env.CI,
     stdout: 'ignore',

+ 16 - 3
apps/app/public/static/locales/en_US/admin.json

@@ -413,6 +413,7 @@
     "azure_storage_account_name": "Storage Account Name",
     "azure_storage_container_name": "Container Name",
     "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+    "azure_note_for_the_only_env_option_cloud": "Azure settings can be changed from the GROWI.cloud admin panel.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "Test connection to mail",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
@@ -425,7 +426,8 @@
     "enable": "Enable",
     "disable": "Disable",
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
-    "note_for_the_only_env_option": "The GCS Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
+    "note_for_the_only_env_option": "The GCS Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+    "note_for_the_only_env_option_cloud": "GCS settings can be changed from the GROWI.cloud admin panel."
   },
   "markdown_settings": {
     "markdown_settings": "Markdown Settings",
@@ -781,7 +783,8 @@
       "revoke_read_only_access": "Revoke read only access",
       "grant_read_only_access": "Grant read only access",
       "send_invitation_email": "Send invitation email",
-      "resend_invitation_email": "Resend invitation email"
+      "resend_invitation_email": "Resend invitation email",
+      "deleted_user": "(Deleted User)"
     },
     "reset_password": "Reset Password",
     "reset_password_modal": {
@@ -881,6 +884,14 @@
     "available_action_list_explanation": "List of actions that can be searched/viewed in the current settings",
     "action_list": "Action List",
     "disable_mode_explanation": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "export": "Export",
+    "export_audit_log": "Export Audit Log",
+    "export_requested": "Export request accepted. You will be notified when the export is complete.",
+    "export_failed": "Failed to start export",
+    "duplicate_export_confirm": "An export with the same conditions is already in progress. Do you want to restart it?",
+    "restart_export": "Restart Export",
+    "confirm_export": "Confirm Export",
+    "disable_mode_explanation_cloud": "Audit log is currently disabled. To enable it, please update the app settings from the GROWI.cloud management screen.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
@@ -906,7 +917,9 @@
     "confirm": "Delete plugin?"
   },
   "cloud_setting_management": {
-    "to_cloud_settings": "Open GROWI.cloud Settings"
+    "to_cloud_settings": "Open GROWI.cloud Settings",
+    "change_from_cloud": "You can change the settings from the GROWI.cloud admin panel.",
+    "storage_change_from_cloud": "The current file upload method is {{fileUploadType}}. Settings can be changed from the GROWI.cloud admin panel."
   },
   "audit_log_action_category": {
     "Page": "Page",

+ 25 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -660,7 +660,9 @@
     }
   },
   "default_ai_assistant": {
-    "not_set": "Default assistant is not set"
+    "not_set": "Default assistant is not set",
+    "open_cloud_settings_to_enable": "Please check the GROWI.cloud management screen to enable AI integration",
+    "to_cloud_settings": "Open GROWI.cloud Settings"
   },
   "ai_assistant_substance": {
     "add_assistant": "Add Assistant",
@@ -861,6 +863,11 @@
     "started_on": "Started on",
     "file_upload_not_configured": "File upload settings are not configured"
   },
+  "audit_log_bulk_export": {
+    "download_expired": "Download period has expired",
+    "job_expired": "Export process was canceled because it took too long",
+    "no_results": "No audit logs matched the specified filters"
+  },
   "message": {
     "successfully_connected": "Successfully Connected!",
     "fail_to_save_access_token": "Failed to save access_token. Please try again.",
@@ -1077,5 +1084,22 @@
     "success-toaster": "Latest text synchronized",
     "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "error-toaster": "Synchronization of the latest text failed"
+  },
+  "toolbar": {
+    "attachments": "Attachments",
+    "bold": "Bold",
+    "bullet_list": "Bullet List",
+    "checklist": "Checklist",
+    "code": "Code",
+    "diagram": "Diagram",
+    "emoji": "Emoji",
+    "heading": "Heading",
+    "italic": "Italic",
+    "numbered_list": "Numbered List",
+    "quote": "Quote",
+    "strikethrough": "Strikethrough",
+    "table": "Table",
+    "template": "Template",
+    "text_formatting": "Text Formatting"
   }
 }

+ 16 - 3
apps/app/public/static/locales/fr_FR/admin.json

@@ -413,6 +413,7 @@
     "azure_storage_account_name": "Nom du compte de stockage",
     "azure_storage_container_name": "Nom du conteneur",
     "azure_note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> .",
+    "azure_note_for_the_only_env_option_cloud": "Les paramètres Azure peuvent être modifiés depuis le panneau d'administration GROWI.cloud.",
     "file_upload": "Téléversement de fichiers",
     "test_connection": "Essai de la connection e-mail",
     "change_setting": "Si ce paramètre n'est pas complètement configuré, les fichiers existants seront inaccessibles.",
@@ -425,7 +426,8 @@
     "enable": "Activer",
     "disable": "Désactiver",
     "use_env_var_if_empty": "Si la valeur dans la base de données est vide, la valeur de variable d'environnement <code>{{variable}}</code> est utilisé.",
-    "note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> ."
+    "note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> .",
+    "note_for_the_only_env_option_cloud": "Les paramètres GCS peuvent être modifiés depuis le panneau d'administration GROWI.cloud."
   },
   "markdown_settings": {
     "markdown_settings": "Markdown",
@@ -781,7 +783,8 @@
       "revoke_read_only_access": "Révoquer permission de lecture",
       "grant_read_only_access": "Permission de lecture-seule",
       "send_invitation_email": "Envoyer courriel d'invitation",
-      "resend_invitation_email": "Renvoyer courriel d'invitation"
+      "resend_invitation_email": "Renvoyer courriel d'invitation",
+      "deleted_user": "(Utilisateur supprimé)"
     },
     "reset_password": "Réinitialiser mot de passe",
     "reset_password_modal": {
@@ -880,6 +883,14 @@
     "available_action_list_explanation": "Liste des actions pouvant être recherchées/vues",
     "action_list": "Liste d'actions",
     "disable_mode_explanation": "Cette fonctionnalité est désactivée. Afin de l'activer, mettre à jour <code>AUDIT_LOG_ENABLED</code> pour true.",
+    "export": "Exporter",
+    "export_audit_log": "Exporter le journal d'audit",
+    "export_requested": "Demande d'exportation acceptée. Vous serez averti lorsque l'exportation sera terminée.",
+    "export_failed": "Échec du démarrage de l'exportation",
+    "duplicate_export_confirm": "Une exportation avec les mêmes conditions est déjà en cours. Voulez-vous la redémarrer ?",
+    "restart_export": "Redémarrer l'exportation",
+    "confirm_export": "Confirmer l'exportation",
+    "disable_mode_explanation_cloud": "Le journal d'audit est actuellement désactivé. Pour l'activer, veuillez modifier les paramètres de l'application depuis l'écran de gestion GROWI.cloud.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
@@ -905,7 +916,9 @@
     "confirm": "Supprimer le plugin?"
   },
   "cloud_setting_management": {
-    "to_cloud_settings": "Ouvrir paramètres GROWI.cloud"
+    "to_cloud_settings": "Ouvrir paramètres GROWI.cloud",
+    "change_from_cloud": "Vous pouvez modifier les paramètres depuis le panneau d'administration GROWI.cloud.",
+    "storage_change_from_cloud": "La méthode de téléversement actuelle est {{fileUploadType}}. Les paramètres peuvent être modifiés depuis le panneau d'administration GROWI.cloud."
   },
   "audit_log_action_category": {
     "Page": "Page",

+ 25 - 1
apps/app/public/static/locales/fr_FR/translation.json

@@ -655,7 +655,9 @@
     }
   },
   "default_ai_assistant": {
-    "not_set": "L'assistant par défaut n'est pas configuré"
+    "not_set": "L'assistant par défaut n'est pas configuré",
+    "open_cloud_settings_to_enable": "Veuillez consulter l'écran de gestion GROWI.cloud pour activer l'intégration AI",
+    "to_cloud_settings": "Ouvrir les paramètres GROWI.cloud"
   },
   "ai_assistant_substance": {
     "add_assistant": "Ajouter un assistant",
@@ -856,6 +858,11 @@
     "started_on": "Commencé le",
     "file_upload_not_configured": "Les paramètres de téléchargement de fichiers ne sont pas configurés"
   },
+  "audit_log_bulk_export": {
+    "download_expired": "La période de téléchargement a expiré",
+    "job_expired": "Le processus d'exportation a été annulé car il a pris trop de temps",
+    "no_results": "Aucun journal d'audit ne correspondait aux filtres spécifiés"
+  },
   "message": {
     "successfully_connected": "Connecté!",
     "fail_to_save_access_token": "Échec de la sauvegarde de access_token.",
@@ -1069,5 +1076,22 @@
     "success-toaster": "Dernière révision synchronisée",
     "skipped-toaster": "Le mode édition doit être activé pour déclencher la synchronisation. Synchronisation annulée.",
     "error-toaster": "Synchronisation échouée"
+  },
+  "toolbar": {
+    "attachments": "Pièces jointes",
+    "bold": "Gras",
+    "bullet_list": "Liste à puces",
+    "checklist": "Liste de contrôle",
+    "code": "Code",
+    "diagram": "Diagramme",
+    "emoji": "Emoji",
+    "heading": "Titre",
+    "italic": "Italique",
+    "numbered_list": "Liste numérotée",
+    "quote": "Citation",
+    "strikethrough": "Barré",
+    "table": "Tableau",
+    "template": "Modèle",
+    "text_formatting": "Mise en forme du texte"
   }
 }

+ 16 - 3
apps/app/public/static/locales/ja_JP/admin.json

@@ -421,6 +421,7 @@
     "azure_storage_account_name": "ストレージアカウント名",
     "azure_storage_container_name": "コンテナ名",
     "azure_note_for_the_only_env_option": "現在Azure設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
+    "azure_note_for_the_only_env_option_cloud": "Azure の設定は GROWI.cloud の管理画面から変更できます",
     "fixed_by_env_var": "環境変数 <code>{{envKey}}={{envVar}}</code> により固定されています。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "test_connection": "接続テスト",
@@ -434,7 +435,8 @@
     "enable": "有効",
     "disable": "無効",
     "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
-    "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
+    "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
+    "note_for_the_only_env_option_cloud": "GCS の設定は GROWI.cloud の管理画面から変更できます"
   },
   "markdown_settings": {
     "markdown_settings": "マークダウン設定",
@@ -790,7 +792,8 @@
       "revoke_read_only_access": "閲覧のみアクセス権を外す",
       "grant_read_only_access": "閲覧のみアクセス権を付与する",
       "send_invitation_email": "招待メールの送信",
-      "resend_invitation_email": "招待メールの再送信"
+      "resend_invitation_email": "招待メールの再送信",
+      "deleted_user": "(削除されたユーザー)"
     },
     "reset_password": "パスワードのリセット",
     "reset_password_modal": {
@@ -890,6 +893,14 @@
     "available_action_list_explanation": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
     "disable_mode_explanation": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "export": "エクスポート",
+    "export_audit_log": "監査ログのエクスポート",
+    "export_requested": "エクスポートリクエストを受け付けました。完了後に通知されます。",
+    "export_failed": "エクスポートの開始に失敗しました",
+    "duplicate_export_confirm": "同じ条件のエクスポートが進行中です。やり直しますか?",
+    "restart_export": "やり直す",
+    "confirm_export": "エクスポートの確認",
+    "disable_mode_explanation_cloud": "現在、監査ログは無効になっています。有効にするには、GROWI.cloud の管理画面からアプリの設定を変更してください。",
     "docs_url": {
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
@@ -915,7 +926,9 @@
     "confirm": "プラグインを削除しますか?"
   },
   "cloud_setting_management": {
-    "to_cloud_settings": "GROWI.cloud の管理画面へ"
+    "to_cloud_settings": "GROWI.cloud の管理画面へ",
+    "change_from_cloud": "GROWI.cloud の管理画面から設定を変更できます。",
+    "storage_change_from_cloud": "現在のファイルアップロード方法は {{fileUploadType}} です。変更は GROWI.cloud の管理画面から行えます。"
   },
   "audit_log_action_category": {
     "Page": "ページ",

+ 25 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -693,7 +693,9 @@
     }
   },
   "default_ai_assistant": {
-    "not_set": "デフォルトアシスタントが設定されていません"
+    "not_set": "デフォルトアシスタントが設定されていません",
+    "open_cloud_settings_to_enable": "AI 連携を有効にするには GROWI.cloud の管理画面をご確認ください",
+    "to_cloud_settings": "GROWI.cloud の管理画面へ"
   },
   "ai_assistant_substance": {
     "add_assistant": "アシスタントを追加する",
@@ -894,6 +896,11 @@
     "started_on": "開始日時",
     "file_upload_not_configured": "ファイルアップロード設定が完了していません"
   },
+  "audit_log_bulk_export": {
+    "download_expired": "ダウンロード期限が切れました",
+    "job_expired": "エクスポート時間が長すぎるため、処理が中断されました",
+    "no_results": "指定されたフィルターに一致する監査ログはありませんでした"
+  },
   "message": {
     "successfully_connected": "接続に成功しました!",
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",
@@ -1110,5 +1117,22 @@
     "success-toaster": "最新の本文を同期しました",
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "error-toaster": "最新の本文の同期に失敗しました"
+  },
+  "toolbar": {
+    "attachments": "添付ファイル",
+    "bold": "太字",
+    "bullet_list": "箇条書きリスト",
+    "checklist": "チェックリスト",
+    "code": "コード",
+    "diagram": "ダイアグラム",
+    "emoji": "絵文字",
+    "heading": "見出し",
+    "italic": "イタリック",
+    "numbered_list": "番号付きリスト",
+    "quote": "引用",
+    "strikethrough": "取り消し線",
+    "table": "テーブル",
+    "template": "テンプレート",
+    "text_formatting": "テキスト書式"
   }
 }

+ 16 - 3
apps/app/public/static/locales/ko_KR/admin.json

@@ -413,6 +413,7 @@
     "azure_storage_account_name": "스토리지 계정 이름",
     "azure_storage_container_name": "컨테이너 이름",
     "azure_note_for_the_only_env_option": "Azure 설정은 환경 변수 값에 의해 제한됩니다.<br>이 설정을 변경하려면 환경 변수 <code>{{env}}</code>의 값을 false로 변경하거나 삭제하십시오.",
+    "azure_note_for_the_only_env_option_cloud": "Azure 설정은 GROWI.cloud 관리 화면에서 변경할 수 있습니다.",
     "file_upload": "파일 업로드 설정용입니다. 파일 업로드 설정을 완료하면 파일 업로드 기능, 프로필 사진 기능 등이 활성화됩니다.",
     "test_connection": "메일 연결 테스트",
     "change_setting": "주의: 이 설정을 완료하지 않으면 지금까지 업로드한 파일에 접근할 수 없습니다.",
@@ -425,7 +426,8 @@
     "enable": "활성화",
     "disable": "비활성화",
     "use_env_var_if_empty": "데이터베이스 값이 비어 있으면 환경 변수 <code>{{variable}}</code>의 값이 사용됩니다.",
-    "note_for_the_only_env_option": "GCS 설정은 환경 변수 값에 의해 제한됩니다.<br>이 설정을 변경하려면 환경 변수 <code>{{env}}</code>의 값을 false로 변경하거나 삭제하십시오."
+    "note_for_the_only_env_option": "GCS 설정은 환경 변수 값에 의해 제한됩니다.<br>이 설정을 변경하려면 환경 변수 <code>{{env}}</code>의 값을 false로 변경하거나 삭제하십시오.",
+    "note_for_the_only_env_option_cloud": "GCS 설정은 GROWI.cloud 관리 화면에서 변경할 수 있습니다."
   },
   "markdown_settings": {
     "markdown_settings": "마크다운 설정",
@@ -781,7 +783,8 @@
       "revoke_read_only_access": "읽기 전용 권한 취소",
       "grant_read_only_access": "읽기 전용 권한 부여",
       "send_invitation_email": "초대 이메일 전송",
-      "resend_invitation_email": "초대 이메일 재전송"
+      "resend_invitation_email": "초대 이메일 재전송",
+      "deleted_user": "(삭제된 사용자)"
     },
     "reset_password": "비밀번호 재설정",
     "reset_password_modal": {
@@ -881,6 +884,14 @@
     "available_action_list_explanation": "현재 설정에서 검색/볼 수 있는 작업 목록",
     "action_list": "작업 목록",
     "disable_mode_explanation": "감사 로그가 현재 비활성화되어 있습니다. 활성화하려면 환경 변수 <code>AUDIT_LOG_ENABLED</code>를 true로 설정하십시오.",
+    "export": "내보내기",
+    "export_audit_log": "감사 로그 내보내기",
+    "export_requested": "내보내기 요청이 접수되었습니다. 내보내기가 완료되면 알림을 받게 됩니다.",
+    "export_failed": "내보내기 시작에 실패했습니다",
+    "duplicate_export_confirm": "동일한 조건의 내보내기가 이미 진행 중입니다. 다시 시작하시겠습니까?",
+    "restart_export": "내보내기 다시 시작",
+    "confirm_export": "내보내기 확인",
+    "disable_mode_explanation_cloud": "현재 감사 로그가 비활성화되어 있습니다. 활성화하려면 GROWI.cloud 관리 화면에서 앱 설정을 변경하십시오.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
@@ -906,7 +917,9 @@
     "confirm": "플러그인 삭제?"
   },
   "cloud_setting_management": {
-    "to_cloud_settings": "GROWI.cloud 설정 열기"
+    "to_cloud_settings": "GROWI.cloud 설정 열기",
+    "change_from_cloud": "GROWI.cloud 관리 화면에서 설정을 변경할 수 있습니다.",
+    "storage_change_from_cloud": "현재 파일 업로드 방법은 {{fileUploadType}} 입니다. 설정은 GROWI.cloud 관리 화면에서 변경할 수 있습니다."
   },
   "audit_log_action_category": {
     "Page": "페이지",

+ 25 - 1
apps/app/public/static/locales/ko_KR/translation.json

@@ -620,7 +620,9 @@
     }
   },
   "default_ai_assistant": {
-    "not_set": "기본 어시스턴트가 설정되지 않았습니다."
+    "not_set": "기본 어시스턴트가 설정되지 않았습니다.",
+    "open_cloud_settings_to_enable": "AI 통합을 활성화하려면 GROWI.cloud 관리 화면을 확인하십시오",
+    "to_cloud_settings": "GROWI.cloud 관리 화면 열기"
   },
   "ai_assistant_substance": {
     "add_assistant": "어시스턴트 추가",
@@ -821,6 +823,11 @@
     "started_on": "시작일",
     "file_upload_not_configured": "파일 업로드 설정이 구성되지 않았습니다."
   },
+  "audit_log_bulk_export": {
+    "download_expired": "다운로드 기간이 만료되었습니다",
+    "job_expired": "수출 프로세스가 너무 오래 걸려 취소되었습니다",
+    "no_results": "지정된 필터에 일치하는 감사 로그가 없습니다"
+  },
   "message": {
     "successfully_connected": "성공적으로 연결되었습니다!",
     "fail_to_save_access_token": "액세스 토큰 저장 실패. 다시 시도하십시오.",
@@ -1037,5 +1044,22 @@
     "success-toaster": "최신 텍스트 동기화됨",
     "skipped-toaster": "편집기가 활성화되지 않아 동기화 건너뜀. 편집기를 열고 다시 시도하십시오.",
     "error-toaster": "최신 텍스트 동기화 실패"
+  },
+  "toolbar": {
+    "attachments": "첨부 파일",
+    "bold": "굵게",
+    "bullet_list": "글머리 기호 목록",
+    "checklist": "체크리스트",
+    "code": "코드",
+    "diagram": "다이어그램",
+    "emoji": "이모지",
+    "heading": "제목",
+    "italic": "기울임꼴",
+    "numbered_list": "번호 매기기 목록",
+    "quote": "인용",
+    "strikethrough": "취소선",
+    "table": "표",
+    "template": "템플릿",
+    "text_formatting": "텍스트 서식"
   }
 }

+ 16 - 3
apps/app/public/static/locales/zh_CN/admin.json

@@ -421,6 +421,7 @@
     "azure_storage_account_name": "Storage Account Name",
     "azure_storage_container_name": "Container Name",
     "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+    "azure_note_for_the_only_env_option_cloud": "Azure 设置可以从 GROWI.cloud 管理页面进行更改。",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{envKey}}={{envVar}}</code>.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "测试邮件服务器连接",
@@ -434,7 +435,8 @@
     "enable": "启用",
     "disable": "停用",
     "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。",
-    "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
+    "note_for_the_only_env_option": "The GCS settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+    "note_for_the_only_env_option_cloud": "GCS 设置可以从 GROWI.cloud 管理页面进行更改。"
   },
   "markdown_settings": {
     "markdown_settings": "Markdown设置",
@@ -790,7 +792,8 @@
       "revoke_read_only_access": "取消只读访问",
       "grant_read_only_access": "给予只读权限",
       "send_invitation_email": "发送邀请邮件",
-      "resend_invitation_email": "重发邀请函"
+      "resend_invitation_email": "重发邀请函",
+      "deleted_user": "(已删除的用户)"
     },
     "reset_password": "重置密码",
     "reset_password_modal": {
@@ -890,6 +893,14 @@
     "available_action_list_explanation": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
     "disable_mode_explanation": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "export": "导出",
+    "export_audit_log": "导出审核日志",
+    "export_requested": "导出请求已接受。导出完成后将通知您。",
+    "export_failed": "导出启动失败",
+    "duplicate_export_confirm": "已有相同条件的导出正在进行中。是否要重新启动它?",
+    "restart_export": "重新启动导出",
+    "confirm_export": "确认导出",
+    "disable_mode_explanation_cloud": "审计日志当前已禁用。要启用它,请从 GROWI.cloud 管理界面更改应用程序设置。",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
@@ -915,7 +926,9 @@
     "confirm": "Delete plugin?"
   },
   "cloud_setting_management": {
-    "to_cloud_settings": "進入 GROWI.cloud 的管理界面"
+    "to_cloud_settings": "進入 GROWI.cloud 的管理界面",
+    "change_from_cloud": "您可以从 GROWI.cloud 管理界面更改设置。",
+    "storage_change_from_cloud": "当前文件上传方式为 {{fileUploadType}}。设置可以从 GROWI.cloud 管理页面进行更改。"
   },
   "audit_log_action_category": {
     "Page": "页面",

+ 25 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -651,7 +651,9 @@
     }
   },
   "default_ai_assistant": {
-    "not_set": "未设置默认助手"
+    "not_set": "未设置默认助手",
+    "open_cloud_settings_to_enable": "请查看 GROWI.cloud 管理界面以启用 AI 集成",
+    "to_cloud_settings": "前往 GROWI.cloud 管理界面"
   },
   "ai_assistant_substance": {
     "add_assistant": "添加助手",
@@ -866,6 +868,11 @@
     "started_on": "开始于",
     "file_upload_not_configured": "未配置文件上传设置"
   },
+  "audit_log_bulk_export": {
+    "download_expired": "下载期限已过期",
+    "job_expired": "导出过程因耗时过长被取消",
+    "no_results": "没有审计日志符合指定筛选条件"
+  },
   "message": {
     "successfully_connected": "连接成功!",
     "fail_to_save_access_token": "无法保存访问令牌。请再试一次。",
@@ -1082,5 +1089,22 @@
     "success-toaster": "同步最新文本",
     "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "error-toaster": "同步最新文本失败"
+  },
+  "toolbar": {
+    "attachments": "附件",
+    "bold": "粗体",
+    "bullet_list": "无序列表",
+    "checklist": "清单",
+    "code": "代码",
+    "diagram": "图表",
+    "emoji": "表情符号",
+    "heading": "标题",
+    "italic": "斜体",
+    "numbered_list": "有序列表",
+    "quote": "引用",
+    "strikethrough": "删除线",
+    "table": "表格",
+    "template": "模板",
+    "text_formatting": "文本格式"
   }
 }

+ 0 - 26
apps/app/regconfig.json

@@ -1,26 +0,0 @@
-{
-  "core": {
-    "workingDir": ".reg",
-    "actualDir": "test/playwright/screenshots",
-    "thresholdRate": 0.001,
-    "addIgnore": true,
-    "ximgdiff": {
-      "invocationType": "client"
-    }
-  },
-  "plugins": {
-    "reg-keygen-git-hash-plugin": true,
-    "reg-notify-github-plugin": {
-      "prCommentBehavior": "new",
-      "setCommitStatus": false,
-      "shortDescription": true,
-      "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
-    },
-    "reg-notify-slack-plugin": {
-      "webhookUrl": "$SLACK_WEBHOOK_URL"
-    },
-    "reg-publish-s3-plugin": {
-      "bucketName": "growi-vrt-snapshots"
-    }
-  }
-}

+ 21 - 11
apps/app/src/client/components/Admin/App/AzureSetting.tsx

@@ -15,6 +15,7 @@ export type AzureSettingMoleculeProps = {
   envAzureStorageAccountName?: string;
   envAzureStorageContainerName?: string;
   onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void;
+  isCloud: boolean;
 };
 
 export const AzureSettingMolecule = (
@@ -30,6 +31,7 @@ export const AzureSettingMolecule = (
     envAzureClientSecret,
     envAzureStorageAccountName,
     envAzureStorageContainerName,
+    isCloud,
   } = props;
 
   return (
@@ -84,17 +86,25 @@ export const AzureSettingMolecule = (
         </div>
       </div>
 
-      {azureUseOnlyEnvVars && (
-        <p
-          className="alert alert-info"
-          // biome-ignore lint/security/noDangerouslySetInnerHtml: includes <br> and <code> from i18n strings
-          dangerouslySetInnerHTML={{
-            __html: t('admin:app_setting.azure_note_for_the_only_env_option', {
-              env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS',
-            }),
-          }}
-        />
-      )}
+      {azureUseOnlyEnvVars &&
+        (isCloud ? (
+          <p className="alert alert-info">
+            {t('admin:app_setting.azure_note_for_the_only_env_option_cloud')}
+          </p>
+        ) : (
+          <p
+            className="alert alert-info"
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: includes <br> and <code> from i18n strings
+            dangerouslySetInnerHTML={{
+              __html: t(
+                'admin:app_setting.azure_note_for_the_only_env_option',
+                {
+                  env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS',
+                },
+              ),
+            }}
+          />
+        ))}
       <table
         className={`table settings-table ${azureUseOnlyEnvVars && 'use-only-env-vars'}`}
       >

+ 64 - 39
apps/app/src/client/components/Admin/App/FileUploadSetting.tsx

@@ -5,6 +5,7 @@ import { useController, useForm } from 'react-hook-form';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { FileUploadType } from '~/interfaces/file-uploader';
+import { useGrowiAppIdForGrowiCloud, useGrowiCloudUri } from '~/states/global';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import { AwsSettingMolecule } from './AwsSetting';
@@ -15,6 +16,9 @@ import { useFileUploadSettings } from './useFileUploadSettings';
 
 const FileUploadSetting = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
+  const growiCloudUri = useGrowiCloudUri();
+  const growiAppIdForGrowiCloud = useGrowiAppIdForGrowiCloud();
+  const isCloud = growiCloudUri != null && growiAppIdForGrowiCloud != null;
   const { data, isLoading, error, updateSettings } = useFileUploadSettings();
 
   const { register, handleSubmit, control, watch, formState } =
@@ -107,44 +111,63 @@ const FileUploadSetting = (): JSX.Element => {
           {t('admin:app_setting.file_upload_method')}
         </span>
 
-        <div className="col-md-6 py-2">
-          {Object.values(FileUploadType).map((type) => {
-            return (
-              <div key={type} className="form-check form-check-inline">
-                <input
-                  type="radio"
-                  className="form-check-input"
-                  name="file-upload-type"
-                  id={`file-upload-type-radio-${type}`}
-                  checked={fileUploadTypeField.value === type}
-                  disabled={data.isFixedFileUploadByEnvVar}
-                  onChange={() => fileUploadTypeField.onChange(type)}
-                />
-                <label
-                  className="form-label form-check-label"
-                  htmlFor={`file-upload-type-radio-${type}`}
-                >
-                  {t(`admin:app_setting.${type}_label`)}
-                </label>
-              </div>
-            );
-          })}
-        </div>
-        {data.isFixedFileUploadByEnvVar && (
-          <p className="alert alert-warning mt-2 text-start offset-3 col-6">
-            <span className="material-symbols-outlined">help</span>
-            <b>FIXED</b>
-            <br />
-            <b
-              // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
-              dangerouslySetInnerHTML={{
-                __html: t('admin:app_setting.fixed_by_env_var', {
-                  envKey: 'FILE_UPLOAD',
-                  envVar: data.envFileUploadType,
-                }),
-              }}
-            />
-          </p>
+        {!isCloud && (
+          <div className="col-md-6 py-2">
+            {Object.values(FileUploadType).map((type) => {
+              return (
+                <div key={type} className="form-check form-check-inline">
+                  <input
+                    type="radio"
+                    className="form-check-input"
+                    name="file-upload-type"
+                    id={`file-upload-type-radio-${type}`}
+                    checked={fileUploadTypeField.value === type}
+                    disabled={data.isFixedFileUploadByEnvVar}
+                    onChange={() => fileUploadTypeField.onChange(type)}
+                  />
+                  <label
+                    className="form-label form-check-label"
+                    htmlFor={`file-upload-type-radio-${type}`}
+                  >
+                    {t(`admin:app_setting.${type}_label`)}
+                  </label>
+                </div>
+              );
+            })}
+          </div>
+        )}
+        {isCloud ? (
+          <div className="alert alert-warning mt-2 text-start offset-3 col-6">
+            <p>
+              {t('admin:cloud_setting_management.storage_change_from_cloud', {
+                fileUploadType: t(`admin:app_setting.${fileUploadType}_label`),
+              })}
+            </p>
+            <a
+              href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
+              className="btn btn-outline-secondary"
+            >
+              <span className="material-symbols-outlined me-1">share</span>
+              {t('admin:cloud_setting_management.to_cloud_settings')}
+            </a>
+          </div>
+        ) : (
+          data.isFixedFileUploadByEnvVar && (
+            <p className="alert alert-warning mt-2 text-start offset-3 col-6">
+              <span className="material-symbols-outlined">help</span>
+              <b>FIXED</b>
+              <br />
+              <b
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+                dangerouslySetInnerHTML={{
+                  __html: t('admin:app_setting.fixed_by_env_var', {
+                    envKey: 'FILE_UPLOAD',
+                    envVar: data.envFileUploadType,
+                  }),
+                }}
+              />
+            </p>
+          )
         )}
       </div>
 
@@ -165,6 +188,7 @@ const FileUploadSetting = (): JSX.Element => {
           envGcsBucket={data.envGcsBucket}
           envGcsUploadNamespace={data.envGcsUploadNamespace}
           onChangeGcsReferenceFileWithRelayMode={gcsRelayModeField.onChange}
+          isCloud={isCloud}
         />
       )}
 
@@ -179,10 +203,11 @@ const FileUploadSetting = (): JSX.Element => {
           envAzureStorageAccountName={data.envAzureStorageAccountName}
           envAzureStorageContainerName={data.envAzureStorageContainerName}
           onChangeAzureReferenceFileWithRelayMode={azureRelayModeField.onChange}
+          isCloud={isCloud}
         />
       )}
 
-      <AdminUpdateButtonRow type="submit" disabled={isLoading} />
+      {!isCloud && <AdminUpdateButtonRow type="submit" disabled={isLoading} />}
     </form>
   );
 };

+ 18 - 11
apps/app/src/client/components/Admin/App/GcsSetting.tsx

@@ -12,6 +12,7 @@ export type GcsSettingMoleculeProps = {
   envGcsBucket?: string;
   envGcsUploadNamespace?: string;
   onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void;
+  isCloud: boolean;
 };
 
 export const GcsSettingMolecule = (
@@ -25,6 +26,7 @@ export const GcsSettingMolecule = (
     envGcsApiKeyJsonPath,
     envGcsBucket,
     envGcsUploadNamespace,
+    isCloud,
   } = props;
 
   return (
@@ -79,17 +81,22 @@ export const GcsSettingMolecule = (
         </div>
       </div>
 
-      {gcsUseOnlyEnvVars && (
-        <p
-          className="alert alert-info"
-          // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
-          dangerouslySetInnerHTML={{
-            __html: t('admin:app_setting.note_for_the_only_env_option', {
-              env: 'GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS',
-            }),
-          }}
-        />
-      )}
+      {gcsUseOnlyEnvVars &&
+        (isCloud ? (
+          <p className="alert alert-info">
+            {t('admin:app_setting.note_for_the_only_env_option_cloud')}
+          </p>
+        ) : (
+          <p
+            className="alert alert-info"
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: includes markup from i18n strings
+            dangerouslySetInnerHTML={{
+              __html: t('admin:app_setting.note_for_the_only_env_option', {
+                env: 'GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS',
+              }),
+            }}
+          />
+        ))}
       <table
         className={`table settings-table ${gcsUseOnlyEnvVars && 'use-only-env-vars'}`}
       >

+ 21 - 1
apps/app/src/client/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -2,9 +2,16 @@ import type { FC } from 'react';
 import React from 'react';
 import { useTranslation } from 'react-i18next';
 
+import { useGrowiAppIdForGrowiCloud, useGrowiCloudUri } from '~/states/global';
+
 export const AuditLogDisableMode: FC = () => {
   const { t } = useTranslation('admin');
 
+  const growiCloudUri = useGrowiCloudUri();
+  const growiAppIdForGrowiCloud = useGrowiAppIdForGrowiCloud();
+
+  const isCloud = growiCloudUri != null && growiAppIdForGrowiCloud != null;
+
   return (
     <div className="ccontainer-lg">
       <div className="container">
@@ -21,9 +28,22 @@ export const AuditLogDisableMode: FC = () => {
               <h3
                 // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
                 dangerouslySetInnerHTML={{
-                  __html: t('audit_log_management.disable_mode_explanation'),
+                  __html: t(
+                    isCloud
+                      ? 'audit_log_management.disable_mode_explanation_cloud'
+                      : 'audit_log_management.disable_mode_explanation',
+                  ),
                 }}
               />
+              {isCloud && (
+                <a
+                  href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
+                  className="btn btn-outline-secondary mt-3"
+                >
+                  <span className="material-symbols-outlined me-1">share</span>
+                  {t('cloud_setting_management.to_cloud_settings')}
+                </a>
+              )}
             </div>
           </div>
         </div>

+ 214 - 0
apps/app/src/client/components/Admin/AuditLog/AuditLogExportModal.tsx

@@ -0,0 +1,214 @@
+import { useCallback, useState } from 'react';
+import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useAtomValue } from 'jotai';
+import { useTranslation } from 'react-i18next';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
+
+import type { IAuditLogBulkExportRequestFilters } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
+import type { SupportedActionType } from '~/interfaces/activity';
+import { auditLogAvailableActionsAtom } from '~/states/server-configurations';
+
+import { DateRangePicker } from './DateRangePicker';
+import { DuplicateExportConfirmModal } from './DuplicateExportConfirmModal';
+import { SearchUsernameTypeahead } from './SearchUsernameTypeahead';
+import { SelectActionDropdown } from './SelectActionDropdown';
+import { useAuditLogExport } from './useAuditLogExport';
+
+type Props = {
+  isOpen: boolean;
+  onClose: () => void;
+  initialStartDate?: Date | null;
+  initialEndDate?: Date | null;
+  initialSelectedUsernames?: string[];
+  initialActionMap?: Map<SupportedActionType, boolean>;
+};
+
+const AuditLogExportModalSubstance = ({
+  onClose,
+  initialStartDate,
+  initialEndDate,
+  initialSelectedUsernames,
+  initialActionMap,
+}: {
+  onClose: () => void;
+  initialStartDate?: Date | null;
+  initialEndDate?: Date | null;
+  initialSelectedUsernames?: string[];
+  initialActionMap?: Map<SupportedActionType, boolean>;
+}): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const auditLogAvailableActionsData = useAtomValue(
+    auditLogAvailableActionsAtom,
+  );
+
+  const [startDate, setStartDate] = useState<Date | null>(
+    initialStartDate ?? null,
+  );
+  const [endDate, setEndDate] = useState<Date | null>(initialEndDate ?? null);
+  const [selectedUsernames, setSelectedUsernames] = useState<string[]>(
+    initialSelectedUsernames ?? [],
+  );
+  const [actionMap, setActionMap] = useState(() =>
+    initialActionMap != null
+      ? new Map(initialActionMap)
+      : new Map<SupportedActionType, boolean>(
+          auditLogAvailableActionsData?.map((action) => [action, true]) ?? [],
+        ),
+  );
+
+  const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
+    setStartDate(dateList[0]);
+    setEndDate(dateList[1]);
+  }, []);
+
+  const actionCheckboxChangedHandler = useCallback(
+    (action: SupportedActionType) => {
+      setActionMap((prev) => {
+        const next = new Map(prev);
+        next.set(action, !next.get(action));
+        return next;
+      });
+    },
+    [],
+  );
+
+  const multipleActionCheckboxChangedHandler = useCallback(
+    (actions: SupportedActionType[], isChecked: boolean) => {
+      setActionMap((prev) => {
+        const next = new Map(prev);
+        actions.forEach((action) => {
+          next.set(action, isChecked);
+        });
+        return next;
+      });
+    },
+    [],
+  );
+
+  const setUsernamesHandler = useCallback((usernames: string[]) => {
+    setSelectedUsernames(usernames);
+  }, []);
+
+  const buildFilters = useCallback(() => {
+    const selectedActionList = Array.from(actionMap.entries())
+      .filter((v) => v[1])
+      .map((v) => v[0]);
+
+    const filters: IAuditLogBulkExportRequestFilters = {};
+
+    if (selectedUsernames.length > 0) {
+      filters.usernames = selectedUsernames;
+    }
+    if (selectedActionList.length > 0) {
+      filters.actions = selectedActionList;
+    }
+    if (startDate != null) {
+      filters.dateFrom = startDate;
+    }
+    if (endDate != null) {
+      const endOfDay = new Date(endDate);
+      endOfDay.setHours(23, 59, 59, 999);
+      filters.dateTo = endOfDay;
+    }
+
+    return filters;
+  }, [actionMap, selectedUsernames, startDate, endDate]);
+
+  const {
+    isExporting,
+    isDuplicateConfirmOpen,
+    exportHandler,
+    restartExportHandler,
+    closeDuplicateConfirm,
+  } = useAuditLogExport(buildFilters, onClose);
+
+  return (
+    <>
+      <ModalHeader tag="h4" toggle={onClose}>
+        {t('audit_log_management.export_audit_log')}
+      </ModalHeader>
+
+      <ModalBody>
+        <div className="mb-3">
+          <div className="form-label">{t('audit_log_management.username')}</div>
+          <SearchUsernameTypeahead
+            onChange={setUsernamesHandler}
+            initialUsernames={initialSelectedUsernames}
+          />
+        </div>
+
+        <div className="mb-3">
+          <div className="form-label">{t('audit_log_management.date')}</div>
+          <DateRangePicker
+            startDate={startDate}
+            endDate={endDate}
+            onChange={datePickerChangedHandler}
+          />
+        </div>
+
+        <div className="mb-3">
+          <div className="form-label">{t('audit_log_management.action')}</div>
+          <SelectActionDropdown
+            actionMap={actionMap}
+            availableActions={auditLogAvailableActionsData || []}
+            onChangeAction={actionCheckboxChangedHandler}
+            onChangeMultipleAction={multipleActionCheckboxChangedHandler}
+          />
+        </div>
+      </ModalBody>
+
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onClose}
+        >
+          {t('export_management.cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={exportHandler}
+          disabled={isExporting}
+        >
+          {isExporting ? (
+            <LoadingSpinner className="me-1 fs-3" />
+          ) : (
+            <span className="material-symbols-outlined me-1">download</span>
+          )}
+          {t('audit_log_management.export')}
+        </button>
+      </ModalFooter>
+
+      <DuplicateExportConfirmModal
+        isOpen={isDuplicateConfirmOpen}
+        onClose={closeDuplicateConfirm}
+        onRestart={restartExportHandler}
+      />
+    </>
+  );
+};
+
+export const AuditLogExportModal = ({
+  isOpen,
+  onClose,
+  initialStartDate,
+  initialEndDate,
+  initialSelectedUsernames,
+  initialActionMap,
+}: Props): JSX.Element => {
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      {isOpen && (
+        <AuditLogExportModalSubstance
+          onClose={onClose}
+          initialStartDate={initialStartDate}
+          initialEndDate={initialEndDate}
+          initialSelectedUsernames={initialSelectedUsernames}
+          initialActionMap={initialActionMap}
+        />
+      )}
+    </Modal>
+  );
+};

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

@@ -1,8 +1,10 @@
 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 'react-datepicker/dist/react-datepicker.css';
+
+import './DateRangePicker.vendor-styles.prebuilt';
 
 type CustomInputProps = {
   value?: string;

Некоторые файлы не были показаны из-за большого количества измененных файлов