Browse Source

Merge pull request #11160 from growilabs/master

Release v7.5.3
Yuki Takei 1 week ago
parent
commit
2e99bdd347
79 changed files with 1704 additions and 500 deletions
  1. 3 1
      .claude/settings.json
  2. 14 0
      .devcontainer/app/devcontainer-lock.json
  3. 15 4
      .devcontainer/app/devcontainer.json
  4. 0 9
      .devcontainer/app/initializeCommand.sh
  5. 7 9
      .devcontainer/app/postCreateCommand.sh
  6. 1 0
      .devcontainer/compose.extend.template.yml
  7. 36 6
      .devcontainer/compose.yml
  8. 1 1
      .devcontainer/pdf-converter/devcontainer.json
  9. 0 9
      .devcontainer/pdf-converter/initializeCommand.sh
  10. 73 0
      .devcontainer/scripts/init-home.sh
  11. 15 0
      .github/mergify.yml
  12. 1 1
      .github/workflows/ci-app-prod.yml
  13. 6 6
      .github/workflows/ci-app.yml
  14. 6 6
      .github/workflows/ci-pdf-converter.yml
  15. 6 6
      .github/workflows/ci-slackbot-proxy.yml
  16. 2 2
      .github/workflows/list-unhealthy-branches.yml
  17. 3 3
      .github/workflows/release-pdf-converter.yml
  18. 3 3
      .github/workflows/release-slackbot-proxy.yml
  19. 4 4
      .github/workflows/release-subpackages.yml
  20. 6 6
      .github/workflows/release.yml
  21. 29 10
      .github/workflows/reusable-app-prod.yml
  22. 3 0
      .gitignore
  23. 35 0
      .kiro/specs/presentation/design.md
  24. 1 1
      .kiro/specs/presentation/spec.json
  25. 9 1
      .mcp.json
  26. 4 0
      .vscode/settings.json
  27. 3 0
      apps/app/.gitignore
  28. 19 0
      apps/app/bin/postbuild-server.ts
  29. 13 24
      apps/app/docker/Dockerfile
  30. 3 1
      apps/app/docker/Dockerfile.dockerignore
  31. 0 3
      apps/app/docker/codebuild/buildspec.yml
  32. 27 15
      apps/app/package.json
  33. 35 0
      apps/app/playwright/20-basic-features/presentation.spec.ts
  34. 6 0
      apps/app/playwright/23-editor/vim-keymap.spec.ts
  35. 13 10
      apps/app/playwright/30-search/search.spec.ts
  36. 16 0
      apps/app/prisma.config.ts
  37. 37 0
      apps/app/prisma/migrate.ts
  38. 0 0
      apps/app/prisma/migrations/.keep
  39. 516 0
      apps/app/prisma/schema.prisma
  40. 6 0
      apps/app/prisma/types.ts
  41. 4 0
      apps/app/resource/Contributor.js
  42. 4 2
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  43. 1 5
      apps/app/src/client/components/Page/SlideRenderer.tsx
  44. 10 2
      apps/app/src/client/components/PageComment/Comment.module.scss
  45. 1 4
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.module.scss
  46. 26 14
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.spec.tsx
  47. 49 51
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx
  48. 14 3
      apps/app/src/client/components/PageList/PageListItemL.tsx
  49. 1 2
      apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx
  50. 1 0
      apps/app/src/features/page/index.ts
  51. 1 0
      apps/app/src/features/page/models/index.ts
  52. 5 0
      apps/app/src/features/page/models/revision.ts
  53. 13 2
      apps/app/src/server/routes/apiv3/page/index.ts
  54. 8 2
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  55. 8 2
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  56. 5 1
      apps/app/src/styles/organisms/_wiki.scss
  57. 5 0
      apps/app/src/utils/prisma.ts
  58. 1 1
      apps/app/tsconfig.json
  59. 6 1
      apps/app/turbo.json
  60. 10 18
      apps/pdf-converter/docker/Dockerfile
  61. 10 0
      apps/pdf-converter/docker/Dockerfile.dockerignore
  62. 2 2
      apps/pdf-converter/package.json
  63. 9 7
      apps/slackbot-proxy/docker/Dockerfile
  64. 2 1
      apps/slackbot-proxy/docker/Dockerfile.dockerignore
  65. 6 6
      apps/slackbot-proxy/package.json
  66. 1 0
      biome.json
  67. 2 44
      package.json
  68. 1 1
      packages/core/package.json
  69. 2 1
      packages/presentation/package.json
  70. 58 0
      packages/presentation/src/client/components/GrowiSlides.spec.tsx
  71. 30 18
      packages/presentation/src/client/components/GrowiSlides.tsx
  72. 1 1
      packages/presentation/src/client/consts/index.ts
  73. 10 0
      packages/presentation/vitest.config.ts
  74. 1 1
      packages/remark-attachment-refs/package.json
  75. 20 0
      packages/remark-drawio/src/components/DrawioViewer.module.scss
  76. 1 1
      packages/remark-lsx/package.json
  77. 49 2
      packages/ui/src/components/UserPicture.tsx
  78. 303 164
      pnpm-lock.yaml
  79. 46 0
      pnpm-workspace.yaml

+ 3 - 1
.claude/settings.json

@@ -55,6 +55,8 @@
   },
   },
   "enabledPlugins": {
   "enabledPlugins": {
     "context7@claude-plugins-official": true,
     "context7@claude-plugins-official": true,
-    "typescript-lsp@claude-plugins-official": true
+    "typescript-lsp@claude-plugins-official": true,
+    "figma@claude-plugins-official": true,
+    "mcp-client-skills@growi-mcp-tools": true
   }
   }
 }
 }

+ 14 - 0
.devcontainer/app/devcontainer-lock.json

@@ -0,0 +1,14 @@
+{
+  "features": {
+    "ghcr.io/devcontainers/features/github-cli:1": {
+      "version": "1.1.0",
+      "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
+      "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
+    },
+    "ghcr.io/devcontainers/features/node:2": {
+      "version": "2.0.0",
+      "resolved": "ghcr.io/devcontainers/features/node@sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f",
+      "integrity": "sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f"
+    }
+  }
+}

+ 15 - 4
.devcontainer/app/devcontainer.json

@@ -7,8 +7,9 @@
   "workspaceFolder": "/workspace/growi",
   "workspaceFolder": "/workspace/growi",
 
 
   "features": {
   "features": {
-    "ghcr.io/devcontainers/features/node:1": {
-      "version": "24.14.0"
+    "ghcr.io/devcontainers/features/node:2": {
+      "version": "24",
+      "pnpmVersion": "11.1.1"
     },
     },
     "ghcr.io/devcontainers/features/github-cli:1": {}
     "ghcr.io/devcontainers/features/github-cli:1": {}
   },
   },
@@ -16,10 +17,18 @@
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // "forwardPorts": [],
   // "forwardPorts": [],
 
 
-  "initializeCommand": "/bin/bash .devcontainer/pdf-converter/initializeCommand.sh",
+  "initializeCommand": "/bin/bash .devcontainer/scripts/init-home.sh",
   // Use 'postCreateCommand' to run commands after the container is created.
   // Use 'postCreateCommand' to run commands after the container is created.
   "postCreateCommand": "/bin/bash ./.devcontainer/app/postCreateCommand.sh",
   "postCreateCommand": "/bin/bash ./.devcontainer/app/postCreateCommand.sh",
 
 
+  // Expose tool install dirs (pnpm global, uv/claude) to every shell spawned by VS Code
+  // (integrated terminal, lifecycle commands, and extension shells such as Claude Code)
+  // without relying on ~/.bashrc, which non-interactive shells don't source.
+  "remoteEnv": {
+    "PNPM_HOME": "/home/vscode/.local/share/pnpm",
+    "PATH": "/home/vscode/.local/share/pnpm/bin:/home/vscode/.local/bin:${containerEnv:PATH}"
+  },
+
   // Configure tool-specific properties.
   // Configure tool-specific properties.
   "customizations": {
   "customizations": {
     "vscode": {
     "vscode": {
@@ -49,7 +58,9 @@
         "mongodb.mongodb-vscode",
         "mongodb.mongodb-vscode",
         // Debug
         // Debug
         "msjsdiag.debugger-for-chrome",
         "msjsdiag.debugger-for-chrome",
-        "firefox-devtools.vscode-firefox-debug"
+        "firefox-devtools.vscode-firefox-debug",
+        // prisma
+        "Prisma.prisma@6.19.0"
       ],
       ],
       "settings": {
       "settings": {
         "terminal.integrated.defaultProfile.linux": "bash"
         "terminal.integrated.defaultProfile.linux": "bash"

+ 0 - 9
.devcontainer/app/initializeCommand.sh

@@ -1,9 +0,0 @@
-# prevent file not found error on docker compose up
-if [ ! -f ".devcontainer/compose.extend.yml" ]; then
-
-cat > ".devcontainer/compose.extend.yml" <<EOF
-services:
-  {}
-EOF
-
-fi

+ 7 - 9
.devcontainer/app/postCreateCommand.sh

@@ -18,17 +18,15 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
 curl -fsSL https://claude.ai/install.sh | bash
 curl -fsSL https://claude.ai/install.sh | bash
 
 
 # Setup pnpm
 # Setup pnpm
-SHELL=bash pnpm setup
-eval "$(cat /home/vscode/.bashrc)"
+export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}"
+export PATH="$PNPM_HOME/bin:$HOME/.local/bin:$PATH"
+mkdir -p "$PNPM_HOME"
+# Use the Docker volume mounted at /workspace/.pnpm-store (see .devcontainer/compose.yml).
+# Without this, pnpm auto-falls-back to <workspace>/.pnpm-store because $HOME
+# (overlay FS) and the workspace (bind mount) are on different filesystems.
 pnpm config set store-dir /workspace/.pnpm-store
 pnpm config set store-dir /workspace/.pnpm-store
 
 
-# Install turbo
-pnpm install turbo --global
-
-# Install typescript-language-server for Claude Code LSP plugin
-# Use `npm -g` (not `pnpm --global`) so the binary lands in nvm's node bin, which is on the default PATH.
-# pnpm's global bin requires PNPM_HOME from ~/.bashrc, which the Claude Code extension's shell doesn't source.
-npm install -g typescript-language-server typescript
+pnpm install --global turbo typescript-language-server typescript
 
 
 # Install dependencies
 # Install dependencies
 turbo run bootstrap
 turbo run bootstrap

+ 1 - 0
.devcontainer/compose.extend.template.yml

@@ -7,5 +7,6 @@ services:
     volumes:
     volumes:
       - ..:/workspace/growi:delegated
       - ..:/workspace/growi:delegated
       - pnpm-store:/workspace/.pnpm-store
       - pnpm-store:/workspace/.pnpm-store
+      - ${HOME}/.claude:/home/vscode/.claude
       - page_bulk_export_tmp:/tmp/page-bulk-export
       - page_bulk_export_tmp:/tmp/page-bulk-export
     tty: true
     tty: true

+ 36 - 6
.devcontainer/compose.yml

@@ -4,6 +4,9 @@ services:
     volumes:
     volumes:
       - ..:/workspace/growi:delegated
       - ..:/workspace/growi:delegated
       - pnpm-store:/workspace/.pnpm-store
       - pnpm-store:/workspace/.pnpm-store
+      - ${HOME}/.claude:/home/vscode/.claude
+      - ${HOME}/.config/gh:/home/vscode/.config/gh
+      - ${HOME}/.config/glab-cli:/home/vscode/.config/glab-cli
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../share:/workspace/share:delegated
       - ../../share:/workspace/share:delegated
       - page_bulk_export_tmp:/tmp/page-bulk-export
       - page_bulk_export_tmp:/tmp/page-bulk-export
@@ -13,21 +16,47 @@ services:
     - opentelemetry-collector-dev-setup_default
     - opentelemetry-collector-dev-setup_default
 
 
   mongo:
   mongo:
-    image: mongo:8.0
+    image: mongo:8.2
     restart: unless-stopped
     restart: unless-stopped
     ports:
     ports:
       - 27017
       - 27017
     volumes:
     volumes:
       - /data/db
       - /data/db
+    healthcheck:
+      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
+      interval: 5s
+      timeout: 5s
+      retries: 20
+
+  # Ensures MongoDB Feature Compatibility Version matches the mongo image.
+  # Required when the mongo image is upgraded while existing data persists in the volume.
+  # https://www.mongodb.com/ja-jp/docs/upcoming/release-notes/8.2-upgrade-standalone/
+  mongo-init:
+    image: mongo:8.2
+    depends_on:
+      mongo:
+        condition: service_healthy
+    restart: 'no'
+    entrypoint:
+      - mongosh
+      - --quiet
+      - --host
+      - mongo:27017
+      - --eval
+      - |
+        const target = '8.2';
+        const result = db.adminCommand({ getParameter: 1, featureCompatibilityVersion: 1 });
+        if (result.featureCompatibilityVersion.version === target) {
+          print(`FCV already $${target}`);
+        } else {
+          db.adminCommand({ setFeatureCompatibilityVersion: target, confirm: true });
+          print(`FCV upgraded: $${result.featureCompatibilityVersion.version} -> $${target}`);
+        }
 
 
   # This container requires '../../growi-docker-compose' repository
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/growilabs/growi-docker-compose.git
   #   cloned from https://github.com/growilabs/growi-docker-compose.git
   elasticsearch:
   elasticsearch:
-    build:
-      context: ../../growi-docker-compose/elasticsearch/v9
-      dockerfile: ./Dockerfile
-      args:
-        - version=9.0.3
+    image: docker.elastic.co/elasticsearch/elasticsearch:9.3.3
     restart: unless-stopped
     restart: unless-stopped
     ports:
     ports:
       - 9200
       - 9200
@@ -42,6 +71,7 @@ services:
     volumes:
     volumes:
       - /usr/share/elasticsearch/data
       - /usr/share/elasticsearch/data
       - ../../growi-docker-compose/elasticsearch/v9/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
       - ../../growi-docker-compose/elasticsearch/v9/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
+      - ../../growi-docker-compose/elasticsearch/v9/config/elasticsearch-plugins.yml:/usr/share/elasticsearch/config/elasticsearch-plugins.yml
 
 
 volumes:
 volumes:
   pnpm-store:
   pnpm-store:

+ 1 - 1
.devcontainer/pdf-converter/devcontainer.json

@@ -11,7 +11,7 @@
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // "forwardPorts": [],
   // "forwardPorts": [],
 
 
-  "initializeCommand": "/bin/bash .devcontainer/pdf-converter/initializeCommand.sh",
+  "initializeCommand": "/bin/bash .devcontainer/scripts/init-home.sh",
   // Use 'postCreateCommand' to run commands after the container is created.
   // Use 'postCreateCommand' to run commands after the container is created.
   "postCreateCommand": "/bin/bash ./.devcontainer/pdf-converter/postCreateCommand.sh",
   "postCreateCommand": "/bin/bash ./.devcontainer/pdf-converter/postCreateCommand.sh",
 
 

+ 0 - 9
.devcontainer/pdf-converter/initializeCommand.sh

@@ -1,9 +0,0 @@
-# prevent file not found error on docker compose up
-if [ ! -f ".devcontainer/compose.extend.yml" ]; then
-
-cat > ".devcontainer/compose.extend.yml" <<EOF
-services:
-  {}
-EOF
-
-fi

+ 73 - 0
.devcontainer/scripts/init-home.sh

@@ -0,0 +1,73 @@
+#!/bin/bash
+# init-home.sh
+#
+# Host-side initialization script. Invoked from devcontainer.json's initializeCommand
+# before `docker compose up`, so that bind-mount target directories exist with the
+# correct ownership/permission on the host. Without this, Docker creates the missing
+# host directories as root, breaking writes from the non-root vscode user inside
+# the container.
+#
+# Idempotent: safe to re-run; never destroys existing content.
+#
+# - Pre-create ~/.claude/, ~/.config/gh/, ~/.config/glab-cli/ on the host so the
+#   compose bind mounts succeed.
+# - Generate .devcontainer/compose.extend.yml as an empty-stub if missing, so
+#   `docker compose up` does not fail with a missing-file error before the user
+#   has authored their own extension.
+# - Write UID/GID into .devcontainer/.env (merge, do not clobber existing keys)
+#   so compose.yml can expand ${UID}/${GID} without depending on shell exports.
+#
+# POSIX-portable (bash + coreutils). No GNU-only flags so macOS/Linux both work.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+DEVCONTAINER_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+DEVCONTAINER_ENV="${DEVCONTAINER_DIR}/.env"
+COMPOSE_EXTEND="${DEVCONTAINER_DIR}/compose.extend.yml"
+
+# ---------------------------------------------------------------------------
+# Pre-create bind-mount target directories on the host.
+# mkdir -p is idempotent and preserves existing contents.
+# Creating them here (as the host user) prevents Docker from creating them
+# as root when the bind mount is first applied.
+# ---------------------------------------------------------------------------
+mkdir -p \
+    "${HOME}/.claude" \
+    "${HOME}/.config/gh" \
+    "${HOME}/.config/glab-cli"
+
+# ---------------------------------------------------------------------------
+# Stub compose.extend.yml: prevents file-not-found errors on `docker compose up`
+# for users who have not authored their own overrides yet.
+# ---------------------------------------------------------------------------
+if [ ! -f "${COMPOSE_EXTEND}" ]; then
+    cat > "${COMPOSE_EXTEND}" <<'EOF'
+services:
+  {}
+EOF
+fi
+
+# ---------------------------------------------------------------------------
+# Merge UID/GID into .devcontainer/.env without clobbering other keys.
+# compose.yml and docker compose auto-load this file as a variable source.
+# id -u / id -g are POSIX-standard on both macOS and Linux.
+# ---------------------------------------------------------------------------
+HOST_UID="$(id -u)"
+HOST_GID="$(id -g)"
+
+if [ -f "${DEVCONTAINER_ENV}" ]; then
+    # Drop any existing UID=/GID= lines, then append fresh values.
+    tmp_env="$(mktemp)"
+    grep -Ev '^(UID|GID)=' "${DEVCONTAINER_ENV}" > "${tmp_env}" || true
+    mv "${tmp_env}" "${DEVCONTAINER_ENV}"
+fi
+
+{
+    echo "UID=${HOST_UID}"
+    echo "GID=${HOST_GID}"
+} >> "${DEVCONTAINER_ENV}"
+
+echo "init-home.sh: initialization complete"
+echo "  host dirs: ~/.claude, ~/.config/gh, ~/.config/glab-cli"
+echo "  ${DEVCONTAINER_ENV}: UID=${HOST_UID}, GID=${HOST_GID}"

+ 15 - 0
.github/mergify.yml

@@ -7,6 +7,15 @@ queue_rules:
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= test-prod-node24 /
       - -check-failure ~= test-prod-node24 /
+      # Explicitly enumerate sub-checks of test-prod-node24 so that matrix-
+      # job level failures (e.g. `run-playwright (chromium, 2/2, 6.0)`)
+      # reliably block merges. The broader `-check-failure ~= test-prod-node24 /`
+      # has historically let such failures through (observed on run
+      # 24828684287 for PR #11032).
+      - -check-failure ~= test-prod-node24 / build-prod
+      - -check-failure ~= test-prod-node24 / launch-prod
+      - -check-failure ~= test-prod-node24 / run-playwright
+      - -check-failure ~= test-prod-node24 / report-playwright
     merge_conditions:
     merge_conditions:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test
       - check-success ~= ci-app-test
@@ -17,6 +26,12 @@ queue_rules:
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= test-prod-node24 /
       - -check-failure ~= test-prod-node24 /
+      # Defensive: same explicit enumeration as queue_conditions so the
+      # merge-time gate cannot be bypassed by a matrix-job level failure.
+      - -check-failure ~= test-prod-node24 / build-prod
+      - -check-failure ~= test-prod-node24 / launch-prod
+      - -check-failure ~= test-prod-node24 / run-playwright
+      - -check-failure ~= test-prod-node24 / report-playwright
 
 
 pull_request_rules:
 pull_request_rules:
   - name: Automatic queue to merge
   - name: Automatic queue to merge

+ 1 - 1
.github/workflows/ci-app-prod.yml

@@ -46,7 +46,7 @@ jobs:
   #       || startsWith( github.base_ref, 'release/' )
   #       || startsWith( github.base_ref, 'release/' )
   #       || startsWith( github.head_ref, 'mergify/merge-queue/' ))
   #       || startsWith( github.head_ref, 'mergify/merge-queue/' ))
   #   with:
   #   with:
-  #     node-version: 22.x
+  #     node-version: 24.x
   #     skip-e2e-test: true
   #     skip-e2e-test: true
   #   secrets:
   #   secrets:
   #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
   #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

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

@@ -49,9 +49,9 @@ jobs:
     steps:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
-      - uses: pnpm/action-setup@v4
+      - uses: pnpm/action-setup@v6
 
 
-      - uses: actions/setup-node@v4
+      - uses: actions/setup-node@v6
         with:
         with:
           node-version: ${{ matrix.node-version }}
           node-version: ${{ matrix.node-version }}
           cache: 'pnpm'
           cache: 'pnpm'
@@ -104,9 +104,9 @@ jobs:
     steps:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
-      - uses: pnpm/action-setup@v4
+      - uses: pnpm/action-setup@v6
 
 
-      - uses: actions/setup-node@v4
+      - uses: actions/setup-node@v6
         with:
         with:
           node-version: ${{ matrix.node-version }}
           node-version: ${{ matrix.node-version }}
           cache: 'pnpm'
           cache: 'pnpm'
@@ -169,9 +169,9 @@ jobs:
     steps:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
-      - uses: pnpm/action-setup@v4
+      - uses: pnpm/action-setup@v6
 
 
-      - uses: actions/setup-node@v4
+      - uses: actions/setup-node@v6
         with:
         with:
           node-version: ${{ matrix.node-version }}
           node-version: ${{ matrix.node-version }}
           cache: 'pnpm'
           cache: 'pnpm'

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

@@ -34,9 +34,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'
@@ -70,9 +70,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'
@@ -109,9 +109,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'

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

@@ -35,9 +35,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'
@@ -100,9 +100,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'
@@ -178,9 +178,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'

+ 2 - 2
.github/workflows/list-unhealthy-branches.yml

@@ -14,9 +14,9 @@ jobs:
       with:
       with:
         fetch-depth: 0
         fetch-depth: 0
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
-        node-version: '18'
+        node-version: '24'
 
 
     - name: List branches
     - name: List branches
       id: list-branches
       id: list-branches

+ 3 - 3
.github/workflows/release-pdf-converter.yml

@@ -74,16 +74,16 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
       with:
       with:
         ref: ${{ github.event.pull_request.base.ref }}
         ref: ${{ github.event.pull_request.base.ref }}
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'

+ 3 - 3
.github/workflows/release-slackbot-proxy.yml

@@ -88,11 +88,11 @@ jobs:
       with:
       with:
         ref: ${{ github.event.pull_request.base.ref }}
         ref: ${{ github.event.pull_request.base.ref }}
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
-        node-version: '18'
+        node-version: '24'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies

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

@@ -33,9 +33,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: '24'
         node-version: '24'
         cache: 'pnpm'
         cache: 'pnpm'
@@ -67,9 +67,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: '24'
         node-version: '24'
         cache: 'pnpm'
         cache: 'pnpm'

+ 6 - 6
.github/workflows/release.yml

@@ -22,11 +22,11 @@ jobs:
       with:
       with:
         ref: ${{ github.event.pull_request.base.ref }}
         ref: ${{ github.event.pull_request.base.ref }}
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
@@ -159,11 +159,11 @@ jobs:
       with:
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies

+ 29 - 10
.github/workflows/reusable-app-prod.yml

@@ -35,9 +35,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'
@@ -69,16 +69,19 @@ jobs:
         tar -zcf production.tar.gz --exclude ./apps/app/.next/cache \
         tar -zcf production.tar.gz --exclude ./apps/app/.next/cache \
           package.json \
           package.json \
           node_modules \
           node_modules \
+          tsconfig.base.json \
           apps/app/.next \
           apps/app/.next \
           apps/app/config \
           apps/app/config \
           apps/app/dist \
           apps/app/dist \
+          apps/app/prisma \
           apps/app/public \
           apps/app/public \
           apps/app/resource \
           apps/app/resource \
           apps/app/tmp \
           apps/app/tmp \
           apps/app/.env.production* \
           apps/app/.env.production* \
           apps/app/node_modules \
           apps/app/node_modules \
           apps/app/next.config.js \
           apps/app/next.config.js \
-          apps/app/package.json
+          apps/app/package.json \
+          apps/app/tsconfig.json
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
 
 
     - name: Upload production files as artifact
     - name: Upload production files as artifact
@@ -109,6 +112,12 @@ jobs:
     needs: [build-prod]
     needs: [build-prod]
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
+    # The extracted production tarball does not include pnpm-workspace.yaml or
+    # packages/*, so pnpm v11's pre-run dep status check would trigger a
+    # `pnpm install` that fails to resolve `workspace:^` references. Skip it.
+    env:
+      pnpm_config_verify_deps_before_run: "false"
+
     strategy:
     strategy:
       matrix:
       matrix:
         mongodb-version: ['6.0', '8.0']
         mongodb-version: ['6.0', '8.0']
@@ -124,9 +133,11 @@ jobs:
         - 9200/tcp
         - 9200/tcp
         env:
         env:
           discovery.type: single-node
           discovery.type: single-node
+          # ES 9.x enables security (HTTPS + auth) by default; disable for plaintext CI access
+          xpack.security.enabled: false
 
 
     steps:
     steps:
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
 
 
@@ -139,8 +150,8 @@ jobs:
       run: |
       run: |
         tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
         tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
 
 
-    # Run after extraction so pnpm/action-setup@v4 can read packageManager from package.json
-    - uses: pnpm/action-setup@v4
+    # Run after extraction so pnpm/action-setup@v6 can read packageManager from package.json
+    - uses: pnpm/action-setup@v6
 
 
     - name: pnpm run server:ci
     - name: pnpm run server:ci
       working-directory: ./apps/app
       working-directory: ./apps/app
@@ -175,6 +186,12 @@ jobs:
       # https://github.com/microsoft/playwright/issues/20010
       # https://github.com/microsoft/playwright/issues/20010
       image: mcr.microsoft.com/playwright:v1.58.2-jammy
       image: mcr.microsoft.com/playwright:v1.58.2-jammy
 
 
+    # Playwright spawns `pnpm run server` inside the extracted prod dir via
+    # GROWI_WEBSERVER_COMMAND. That dir lacks pnpm-workspace.yaml and packages/*,
+    # so pnpm v11's pre-run dep status check would fail. Skip it.
+    env:
+      pnpm_config_verify_deps_before_run: "false"
+
     strategy:
     strategy:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
@@ -193,13 +210,15 @@ jobs:
         - 9200/tcp
         - 9200/tcp
         env:
         env:
           discovery.type: single-node
           discovery.type: single-node
+          # ES 9.x enables security (HTTPS + auth) by default; disable for plaintext CI access
+          xpack.security.enabled: false
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'
@@ -304,9 +323,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
-    - uses: pnpm/action-setup@v4
+    - uses: pnpm/action-setup@v6
 
 
-    - uses: actions/setup-node@v4
+    - uses: actions/setup-node@v6
       with:
       with:
         node-version: ${{ inputs.node-version }}
         node-version: ${{ inputs.node-version }}
         cache: 'pnpm'
         cache: 'pnpm'

+ 3 - 0
.gitignore

@@ -43,6 +43,9 @@ yarn-error.log*
 *.code-workspace
 *.code-workspace
 *.timestamp-*.mjs
 *.timestamp-*.mjs
 
 
+# UTCP configuration file
+.utcp_config.json
+
 # turborepo
 # turborepo
 .turbo
 .turbo
 
 

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

@@ -185,6 +185,8 @@ type SlidesProps = {
 - Render slide content via ReactMarkdown with section extraction
 - Render slide content via ReactMarkdown with section extraction
 - Apply Marp container CSS from pre-extracted constants (no runtime Marp dependency)
 - Apply Marp container CSS from pre-extracted constants (no runtime Marp dependency)
 - Use `MARP_CONTAINER_CLASS_NAME` from shared constants module
 - Use `MARP_CONTAINER_CLASS_NAME` from shared constants module
+- Treat the incoming `rendererOptions` as a read-only shared reference (see Risks & Mitigations); derive a new options object via `useMemo` and never mutate the input
+- Guard for `rendererOptions == null` (and missing `remarkPlugins` / `components`) with an early return; the `?` on `PresentationOptions.rendererOptions` is intentional and reflects SWR loading state
 
 
 **Dependencies**
 **Dependencies**
 - Inbound: Slides — rendering delegation (P0)
 - Inbound: Slides — rendering delegation (P0)
@@ -282,3 +284,36 @@ export const presentationMarpit: Marp;
 - Validation: Script exits with error if CSS extraction produces empty output
 - Validation: Script exits with error if CSS extraction produces empty output
 - Risks: Marp options must stay synchronized with `growi-marpit.ts`
 - Risks: Marp options must stay synchronized with `growi-marpit.ts`
 
 
+## Risks & Mitigations
+
+### `rendererOptions` undefined during SWR loading
+
+Callers in `apps/app` obtain `rendererOptions` from `usePresentationViewOptions()`, which is an SWR hook. During the loading window the value is `undefined`. `PresentationOptions.rendererOptions` is therefore declared optional, and `GrowiSlides` performs an early-return null guard.
+
+Defense is layered across four points; removing any single layer has historically caused regressions (PR #11110 / Redmine #183154):
+
+| Layer | Where | Purpose |
+|---|---|---|
+| Type signature | `consts/index.ts` — `rendererOptions?: ReactMarkdownOptions` | Force callers to acknowledge the loading state at compile time |
+| Caller guard | `apps/app` `SlideRenderer.tsx`, `PagePresentationModal.tsx` — no `as ReactMarkdownOptions` casts, must propagate `undefined` honestly | Prevent the loading `undefined` from masquerading as a value |
+| Component guard | `GrowiSlides.tsx` — early return when `rendererOptions == null` | Last line of defense for inline `slide: true` route, which has no caller-side `<RendererErrorMessage />` |
+| E2E | `apps/app/playwright/20-basic-features/presentation.spec.ts` — reload step | Exercises the SWR loading path that unit tests with mocked modules cannot reach |
+
+**Do not remove the optional `?`, the null guard, or add an `as` cast on grounds of "the type is required" — the optionality is intentional and reflects runtime reality.**
+
+### `rendererOptions` is a shared SWR cache reference
+
+The same `rendererOptions` object is returned by SWR to both `SlideRenderer` and `PagePresentationModal`. `GrowiSlides` must **not** mutate it; doing so leaks state across components and causes pushed remark plugins to accumulate across re-renders (incompatible with React StrictMode and concurrent rendering).
+
+Derive a new options object with `useMemo`, spreading `remarkPlugins` and `components` into fresh arrays/objects before adding `extractSections.remarkPlugin` and the section component. Keep the memo dependency list aligned with all derived inputs (`rendererOptions`, `isDarkMode`, `disableSeparationByHeader`, `presentation`).
+
+### Revalidation Triggers
+
+Re-run validation if any of the following change:
+
+- `PresentationOptions.rendererOptions` type (especially making it required again)
+- `usePresentationViewOptions` loading semantics
+- Null guards in `GrowiSlides`, `SlideRenderer`, or `PagePresentationModal`
+- Any `as ReactMarkdownOptions` cast added in `@growi/presentation` callers
+- Marp library major upgrade (regenerate `marpit-base-css.vendor-styles.prebuilt.ts`)
+

+ 1 - 1
.kiro/specs/presentation/spec.json

@@ -1,7 +1,7 @@
 {
 {
   "feature_name": "presentation",
   "feature_name": "presentation",
   "created_at": "2026-03-05T12:00:00Z",
   "created_at": "2026-03-05T12:00:00Z",
-  "updated_at": "2026-03-23T00:00:00Z",
+  "updated_at": "2026-05-12T00:00:00Z",
   "language": "en",
   "language": "en",
   "phase": "implementation-complete",
   "phase": "implementation-complete",
   "cleanup_completed": true,
   "cleanup_completed": true,

+ 9 - 1
.mcp.json

@@ -1,3 +1,11 @@
 {
 {
-  "mcpServers": {}
+  "mcpServers": {
+    "code-mode": {
+      "command": "npx",
+      "args": ["-y", "@utcp/code-mode-mcp"],
+      "env": {
+        "UTCP_CONFIG_FILE": "/workspace/growi/.utcp_config.json"
+      }
+    }
+  }
 }
 }

+ 4 - 0
.vscode/settings.json

@@ -21,6 +21,10 @@
     "editor.defaultFormatter": "biomejs.biome"
     "editor.defaultFormatter": "biomejs.biome"
   },
   },
 
 
+  "[prisma]": {
+    "editor.defaultFormatter": "Prisma.prisma"
+  },
+
   // use vscode-stylelint
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "stylelint.validate": ["css", "less", "scss"],
   "stylelint.validate": ["css", "less", "scss"],

+ 3 - 0
apps/app/.gitignore

@@ -23,3 +23,6 @@ next.config.js
 
 
 # cache
 # cache
 /.swc/
 /.swc/
+
+# prisma
+/src/generated/prisma

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

@@ -20,6 +20,8 @@ const TRANSPILED_DIR = 'transpiled';
 const DIST_DIR = 'dist';
 const DIST_DIR = 'dist';
 const SRC_SUBDIR = `${TRANSPILED_DIR}/src`;
 const SRC_SUBDIR = `${TRANSPILED_DIR}/src`;
 const CONFIG_SUBDIR = `${TRANSPILED_DIR}/config`;
 const CONFIG_SUBDIR = `${TRANSPILED_DIR}/config`;
+const PRISMA_SRC_DIR = 'src/generated/prisma';
+const PRISMA_DIST_DIR = `${DIST_DIR}/generated/prisma`;
 
 
 // List transpiled contents for debugging
 // List transpiled contents for debugging
 // biome-ignore lint/suspicious/noConsole: This is a build script, console output is expected.
 // biome-ignore lint/suspicious/noConsole: This is a build script, console output is expected.
@@ -40,3 +42,20 @@ if (existsSync(CONFIG_SUBDIR)) {
 
 
 // Remove leftover transpiled directory
 // Remove leftover transpiled directory
 rmSync(TRANSPILED_DIR, { recursive: true, force: true });
 rmSync(TRANSPILED_DIR, { recursive: true, force: true });
+
+// Copy Prisma native engine binaries from src to dist.
+// tspc only compiles TypeScript files, so .so.node engine files must be copied manually.
+if (existsSync(PRISMA_SRC_DIR)) {
+  // biome-ignore lint/suspicious/noConsole: This is a build script, console output is expected.
+  console.log(
+    `Copying Prisma engine files from ${PRISMA_SRC_DIR} to ${PRISMA_DIST_DIR}...`,
+  );
+  const engineFiles = readdirSync(PRISMA_SRC_DIR).filter((f) =>
+    f.endsWith('.node'),
+  );
+  for (const file of engineFiles) {
+    cpSync(`${PRISMA_SRC_DIR}/${file}`, `${PRISMA_DIST_DIR}/${file}`);
+    // biome-ignore lint/suspicious/noConsole: This is a build script, console output is expected.
+    console.log(`  Copied: ${file}`);
+  }
+}

+ 13 - 24
apps/app/docker/Dockerfile

@@ -2,31 +2,26 @@
 
 
 ARG NODE_VERSION=24
 ARG NODE_VERSION=24
 ARG OPT_DIR="/opt"
 ARG OPT_DIR="/opt"
-ARG PNPM_HOME="/root/.local/share/pnpm"
 
 
 ##
 ##
-## base — DHI dev image with pnpm + turbo
+## base — official Node.js image with pnpm + turbo
 ##
 ##
-FROM dhi.io/node:24-debian13-dev AS base
+FROM node:24-bookworm AS base
 
 
 ARG OPT_DIR
 ARG OPT_DIR
-ARG PNPM_HOME
 
 
 WORKDIR $OPT_DIR
 WORKDIR $OPT_DIR
 
 
-# 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 --no-install-recommends ca-certificates wget
-
-# 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"
+# Activate corepack so the pnpm version pinned in the workspace package.json
+# "packageManager" field is used (avoids drift between Dockerfile and local/CI).
+RUN corepack enable
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME/bin:$PATH"
 
 
 # Install turbo globally
 # Install turbo globally
-RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
-  pnpm add turbo --global
+# Note: no cache mount here — pnpm global install links binaries into the store,
+# and the BuildKit cache mount would be unmounted after RUN, breaking those links.
+RUN pnpm add turbo --global
 
 
 
 
 ##
 ##
@@ -53,21 +48,15 @@ RUN turbo prune @growi/app @growi/pdf-converter --docker
 FROM base AS deps
 FROM base AS deps
 
 
 ARG OPT_DIR
 ARG OPT_DIR
-ARG PNPM_HOME
-
-ENV PNPM_HOME=$PNPM_HOME
-ENV PATH="$PNPM_HOME:$PATH"
 
 
 WORKDIR $OPT_DIR
 WORKDIR $OPT_DIR
 
 
 # Copy only package manifests and lockfile for dependency caching
 # Copy only package manifests and lockfile for dependency caching
 COPY --from=pruner $OPT_DIR/out/json/ .
 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
+# --ignore-scripts: postinstall (prisma generate) needs full source, runs in builder stage.
+RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
+  pnpm install --frozen-lockfile --ignore-scripts
 
 
 
 
 ##
 ##

+ 3 - 1
apps/app/docker/Dockerfile.dockerignore

@@ -22,7 +22,9 @@ out
 # ============================================================
 # ============================================================
 # Unrelated apps
 # Unrelated apps
 # ============================================================
 # ============================================================
-apps/slackbot-proxy
+apps/**
+!apps/app
+!apps/pdf-converter
 
 
 # ============================================================
 # ============================================================
 # Test files
 # Test files

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

@@ -22,6 +22,3 @@ phases:
     commands:
     commands:
       - docker push $IMAGE_TAG
       - docker push $IMAGE_TAG
 
 
-cache:
-  paths:
-    - .pnpm-store/**/*

+ 27 - 15
apps/app/package.json

@@ -1,31 +1,35 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.5.2",
+  "version": "7.5.3-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
+    "postinstall": "prisma generate",
     "build": "run-p build:*",
     "build": "run-p build:*",
     "start": "next start",
     "start": "next start",
     "build:client": "next build",
     "build:client": "next build",
     "build:server": "cross-env NODE_ENV=production tspc -p tsconfig.build.server.json",
     "build:server": "cross-env NODE_ENV=production tspc -p tsconfig.build.server.json",
     "postbuild:server": "node bin/postbuild-server.ts",
     "postbuild:server": "node bin/postbuild-server.ts",
-    "clean": "rimraf dist transpiled .next next.config.js",
+    "clean": "rimraf dist transpiled .next next.config.js src/generated/prisma",
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "pnpm run server --ci",
     "server:ci": "pnpm run server --ci",
     "preserver": "cross-env NODE_ENV=production pnpm run migrate",
     "preserver": "cross-env NODE_ENV=production pnpm run migrate",
     "pre:styles-commons": "vite build -c vite.vendor-styles-commons.ts",
     "pre:styles-commons": "vite build -c vite.vendor-styles-commons.ts",
     "pre:styles-components": "vite build --config vite.vendor-styles-components.ts",
     "pre:styles-components": "vite build --config vite.vendor-styles-components.ts",
-    "migrate": "node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js",
+    "migrate": "pnpm run migrate:migrate-mongo && pnpm run migrate:umzug",
+    "migrate:migrate-mongo": "node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js",
+    "migrate:umzug": "pnpm run ts-node prisma/migrate.ts up",
     "//// for development": "",
     "//// for development": "",
     "dev": "cross-env NODE_ENV=development nodemon --exec pnpm run ts-node --inspect src/server/app.ts",
     "dev": "cross-env NODE_ENV=development nodemon --exec pnpm run ts-node --inspect src/server/app.ts",
     "dev:pre:styles-commons": "pnpm run pre:styles-commons --mode dev",
     "dev:pre:styles-commons": "pnpm run pre:styles-commons --mode dev",
     "dev:pre:styles-components": "pnpm run pre:styles-components",
     "dev:pre:styles-components": "pnpm run pre:styles-components",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
+    "dev:umzug": "cross-env NODE_ENV=development pnpm run ts-node prisma/migrate.ts",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
-    "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",
-    "dev:migrate:up": "pnpm run dev:migrate-mongo up -f config/migrate-mongo-config.js",
-    "dev:migrate:down": "pnpm run dev:migrate-mongo down -f config/migrate-mongo-config.js",
+    "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js && pnpm run dev:umzug executed && pnpm run dev:umzug pending",
+    "dev:migrate:up": "pnpm run dev:migrate-mongo up -f config/migrate-mongo-config.js && pnpm run dev:umzug up",
+    "dev:migrate:down": "pnpm run dev:umzug down && pnpm run dev:migrate-mongo down -f config/migrate-mongo-config.js",
     "//// for CI": "",
     "//// for CI": "",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "tsgo --noEmit",
     "lint:typecheck": "tsgo --noEmit",
@@ -45,15 +49,17 @@
     "//// misc": "",
     "//// misc": "",
     "console": "npm run repl",
     "console": "npm run repl",
     "repl": "cross-env NODE_ENV=development npm run ts-node src/server/repl.ts",
     "repl": "cross-env NODE_ENV=development npm run ts-node src/server/repl.ts",
+    "prisma:generate": "prisma generate",
+    "prisma:pull": "prisma db pull",
     "openapi:build:generate-operation-ids": "vite build -c bin/openapi/generate-operation-ids/vite.config.ts",
     "openapi:build:generate-operation-ids": "vite build -c bin/openapi/generate-operation-ids/vite.config.ts",
     "openapi:generate-spec:apiv3": "sh bin/openapi/generate-spec-apiv3.sh",
     "openapi:generate-spec:apiv3": "sh bin/openapi/generate-spec-apiv3.sh",
     "openapi:generate-spec:apiv1": "sh bin/openapi/generate-spec-apiv1.sh",
     "openapi:generate-spec:apiv1": "sh bin/openapi/generate-spec-apiv1.sh",
     "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
     "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
-    "version:patch": "pnpm version patch",
-    "version:prerelease": "pnpm version prerelease --preid=RC",
-    "version:prepatch": "pnpm version prepatch --preid=RC",
-    "version:preminor": "pnpm version preminor --preid=RC",
-    "version:premajor": "pnpm version premajor --preid=RC"
+    "version:patch": "pnpm version patch --no-git-tag-version --no-git-checks",
+    "version:prerelease": "pnpm version prerelease --preid=RC --no-git-tag-version --no-git-checks",
+    "version:prepatch": "pnpm version prepatch --preid=RC --no-git-tag-version --no-git-checks",
+    "version:preminor": "pnpm version preminor --preid=RC --no-git-tag-version --no-git-checks",
+    "version:premajor": "pnpm version premajor --preid=RC --no-git-tag-version --no-git-checks"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
     "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM. API breaking changes require separate migration effort."
     "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM. API breaking changes require separate migration effort."
@@ -101,19 +107,21 @@
     "@marp-team/marp-core": "^3.9.1",
     "@marp-team/marp-core": "^3.9.1",
     "@marp-team/marpit": "^2.6.1",
     "@marp-team/marpit": "^2.6.1",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/api": "^1.9.0",
-    "@opentelemetry/auto-instrumentations-node": "^0.60.1",
+    "@opentelemetry/auto-instrumentations-node": "^0.75.0",
     "@opentelemetry/exporter-metrics-otlp-grpc": "^0.202.0",
     "@opentelemetry/exporter-metrics-otlp-grpc": "^0.202.0",
     "@opentelemetry/exporter-trace-otlp-grpc": "^0.202.0",
     "@opentelemetry/exporter-trace-otlp-grpc": "^0.202.0",
     "@opentelemetry/resources": "^2.0.1",
     "@opentelemetry/resources": "^2.0.1",
     "@opentelemetry/sdk-metrics": "^2.0.1",
     "@opentelemetry/sdk-metrics": "^2.0.1",
-    "@opentelemetry/sdk-node": "^0.202.0",
+    "@opentelemetry/sdk-node": "^0.217.0",
     "@opentelemetry/sdk-trace-node": "^2.0.1",
     "@opentelemetry/sdk-trace-node": "^2.0.1",
     "@opentelemetry/semantic-conventions": "^1.34.0",
     "@opentelemetry/semantic-conventions": "^1.34.0",
+    "@prisma/client": "^6.19.2",
     "@replit/codemirror-emacs": "^6.1.0",
     "@replit/codemirror-emacs": "^6.1.0",
     "@replit/codemirror-vim": "^6.2.1",
     "@replit/codemirror-vim": "^6.2.1",
     "@replit/codemirror-vscode-keymap": "^6.0.2",
     "@replit/codemirror-vscode-keymap": "^6.0.2",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
+    "@swc/helpers": "^0.5.18",
     "@tanstack/react-virtual": "^3.13.12",
     "@tanstack/react-virtual": "^3.13.12",
     "@types/async": "^3.2.24",
     "@types/async": "^3.2.24",
     "@types/multer": "^1.4.12",
     "@types/multer": "^1.4.12",
@@ -186,7 +194,7 @@
     "migrate-mongo": "^11.0.0",
     "migrate-mongo": "^11.0.0",
     "mkdirp": "^1.0.3",
     "mkdirp": "^1.0.3",
     "mongodb": "^4.17.2",
     "mongodb": "^4.17.2",
-    "mongoose": "^6.13.6",
+    "mongoose": "^6.13.9",
     "mongoose-gridfs": "^1.3.0",
     "mongoose-gridfs": "^1.3.0",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
     "mongoose-unique-validator": "^2.0.3",
@@ -194,7 +202,7 @@
     "multer": "~1.4.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
     "mustache": "^4.2.0",
-    "next": "^16.2.1",
+    "next": "^16.2.6",
     "next-dynamic-loading-props": "^0.1.1",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-i18next": "^15.3.1",
     "next-themes": "^0.4.6",
     "next-themes": "^0.4.6",
@@ -267,9 +275,12 @@
     "swr": "^2.3.2",
     "swr": "^2.3.2",
     "throttle-debounce": "^5.0.0",
     "throttle-debounce": "^5.0.0",
     "ts-deepmerge": "^6.2.0",
     "ts-deepmerge": "^6.2.0",
+    "ts-node": "^10.9.2",
+    "tsconfig-paths": "^4.2.0",
     "tslib": "^2.8.0",
     "tslib": "^2.8.0",
     "uglifycss": "^0.0.29",
     "uglifycss": "^0.0.29",
     "uid-safe": "^2.1.5",
     "uid-safe": "^2.1.5",
+    "umzug": "^3.8.2",
     "unified": "^11.0.0",
     "unified": "^11.0.0",
     "unist-util-visit": "^5.0.0",
     "unist-util-visit": "^5.0.0",
     "unstated": "^2.1.1",
     "unstated": "^2.1.1",
@@ -334,6 +345,7 @@
     "mongodb-connection-string-url": "^7.0.0",
     "mongodb-connection-string-url": "^7.0.0",
     "mongodb-memory-server-core": "^9.1.1",
     "mongodb-memory-server-core": "^9.1.1",
     "openapi-typescript": "^7.8.0",
     "openapi-typescript": "^7.8.0",
+    "prisma": "^6.19.2",
     "rehype-rewrite": "^4.0.2",
     "rehype-rewrite": "^4.0.2",
     "remark-github-admonitions-to-directives": "^2.0.0",
     "remark-github-admonitions-to-directives": "^2.0.0",
     "sass": "^1.53.0",
     "sass": "^1.53.0",

+ 35 - 0
apps/app/playwright/20-basic-features/presentation.spec.ts

@@ -15,3 +15,38 @@ test('Presentation', async ({ page }) => {
     page.getByRole('application').getByRole('heading', { level: 1 }),
     page.getByRole('application').getByRole('heading', { level: 1 }),
   ).toHaveText(/Welcome to GROWI/);
   ).toHaveText(/Welcome to GROWI/);
 });
 });
+
+test('Slide page (slide: true frontmatter) renders without crashing', async ({
+  page,
+}) => {
+  await page.goto('/Sandbox/slide-test');
+
+  // open the editor
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // fill the editor with slide content
+  await page
+    .locator('.cm-content')
+    .fill('---\nslide: true\n---\n# Slide 1\n---\n# Slide 2');
+
+  // The editor preview must finish rendering both slides through the marpit
+  // pipeline before saving — this is the slide-mode observable contract and
+  // also proves the preview did not crash on slide content.
+  const previewSlides = page
+    .getByTestId('page-editor-preview-body')
+    .locator('svg[data-marpit-svg]');
+  await expect(previewSlides).toHaveCount(2);
+
+  // save
+  await page.keyboard.press('Control+s');
+
+  // view mode must render the slide deck after save
+  await page.getByTestId('view-button').click();
+  await expect(page.locator('.slides')).toBeVisible();
+
+  // reload exercises the SWR loading path where rendererOptions is briefly
+  // undefined; the slide page must still render without crashing.
+  await page.reload();
+  await expect(page.locator('.slides')).toBeVisible();
+});

+ 6 - 0
apps/app/playwright/23-editor/vim-keymap.spec.ts

@@ -60,6 +60,12 @@ test.describe
     test('Write command (:w) should save the page successfully', async ({
     test('Write command (:w) should save the page successfully', async ({
       page,
       page,
     }) => {
     }) => {
+      // Focus the editor and ensure Normal mode — beforeEach re-navigates, so
+      // the editor may not have focus yet and CodeMirror's Vim extension may
+      // need a keystroke to settle into Normal mode on webkit.
+      await page.locator('.cm-content').click();
+      await page.keyboard.press('Escape');
+
       // Enter command mode
       // Enter command mode
       await page.keyboard.type(':');
       await page.keyboard.type(':');
       await expect(page.locator('.cm-vim-panel')).toBeVisible();
       await expect(page.locator('.cm-vim-panel')).toBeVisible();

+ 13 - 10
apps/app/playwright/30-search/search.spec.ts

@@ -28,10 +28,6 @@ test('checkboxes behaviors', async ({ page }) => {
   await page.getByTestId('cb-select').first().click({ force: true });
   await page.getByTestId('cb-select').first().click({ force: true });
 
 
   // Click the select all checkbox
   // Click the select all checkbox
-  await page
-    .getByTestId('delete-control-button')
-    .first()
-    .click({ force: true });
   await page.getByTestId('cb-select-all').click({ force: true });
   await page.getByTestId('cb-select-all').click({ force: true });
 
 
   // Unclick the first checkbox after selecting all
   // Unclick the first checkbox after selecting all
@@ -76,8 +72,15 @@ test.describe
       await page.locator('.grw-side-contents-sticky-container').isVisible();
       await page.locator('.grw-side-contents-sticky-container').isVisible();
       await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
       await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
       await expect(page.locator('#edit-tag-modal')).toBeVisible();
       await expect(page.locator('#edit-tag-modal')).toBeVisible();
-      await page.locator('.rbt-input-main').fill(tag);
-      await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
+      // Use pressSequentially to fire per-character input events that the
+      // AsyncTypeahead listens to; fill() can be too fast to trigger the
+      // debounced onSearch reliably on CI.
+      await page.locator('.rbt-input-main').pressSequentially(tag);
+      const typeaheadItem = page.locator(
+        '#tag-typeahead-asynctypeahead-item-0',
+      );
+      await expect(typeaheadItem).toBeVisible({ timeout: 15000 });
+      await typeaheadItem.click();
       await page.getByTestId('tag-edit-done-btn').click();
       await page.getByTestId('tag-edit-done-btn').click();
     });
     });
 
 
@@ -99,11 +102,11 @@ test.describe
     test('Successfully order page search results by tag', async ({ page }) => {
     test('Successfully order page search results by tag', async ({ page }) => {
       await page.goto('/');
       await page.goto('/');
 
 
-      await page.locator('.grw-tag-simple-bar').locator('a').click();
+      await page.locator('.grw-tag-simple-bar').locator('button').click();
 
 
-      expect(page.getByTestId('search-result-base')).toBeVisible();
-      expect(page.getByTestId('search-result-list')).toBeVisible();
-      expect(page.getByTestId('search-result-content')).toBeVisible();
+      await expect(page.getByTestId('search-result-base')).toBeVisible();
+      await expect(page.getByTestId('search-result-list')).toBeVisible();
+      await expect(page.getByTestId('search-result-content')).toBeVisible();
     });
     });
   });
   });
 
 

+ 16 - 0
apps/app/prisma.config.ts

@@ -0,0 +1,16 @@
+import { config } from 'dotenv-flow';
+import { defineConfig } from 'prisma/config';
+
+config();
+
+// biome-ignore lint/style/noDefaultExport: prisma requires a default export
+export default defineConfig({
+  schema: 'prisma/schema.prisma',
+  migrations: {
+    path: 'prisma/migrations',
+  },
+  engine: 'classic',
+  datasource: {
+    url: process.env.MONGO_URI,
+  },
+});

+ 37 - 0
apps/app/prisma/migrate.ts

@@ -0,0 +1,37 @@
+/**
+ * umzug cli
+ *
+ * Usage:
+ *   pnpm ts-node prisma/migrate.ts
+ */
+import { resolve } from 'node:path';
+import { MongoClient } from 'mongodb';
+import { MongoDBStorage, Umzug } from 'umzug';
+
+(async () => {
+  const url = process.env.MONGO_URI;
+  if (url === undefined) {
+    throw new Error('MONGO_URI is required');
+  }
+  const { prisma } = await import(
+    process.env.NODE_ENV === 'production'
+      ? '../dist/utils/prisma'
+      : '../src/utils/prisma'
+  );
+  const client = new MongoClient(url);
+  await client.connect();
+
+  const umzug = new Umzug({
+    migrations: { glob: resolve(__dirname, '../prisma/migrations/*.(ts|js)') },
+    context: prisma,
+    storage: new MongoDBStorage({
+      connection: client.db(),
+    }),
+    logger: console,
+  });
+
+  if (require.main === module) {
+    await umzug.runAsCLI();
+    process.exit(0);
+  }
+})();

+ 0 - 0
apps/app/prisma/migrations/.keep


+ 516 - 0
apps/app/prisma/schema.prisma

@@ -0,0 +1,516 @@
+generator client {
+  provider = "prisma-client"
+  output   = "../src/generated/prisma"
+}
+
+datasource db {
+  provider = "mongodb"
+  url      = env("MONGO_URI")
+}
+
+type ActivitiesSnapshot {
+  id       String @map("_id") @db.ObjectId
+  username String
+}
+
+type AiassistantsGrantedGroupsForAccessScope {
+  /// Field referred in an index, but found no data to define the type.
+  item Json?
+}
+
+type AiassistantsGrantedGroupsForShareScope {
+  /// Field referred in an index, but found no data to define the type.
+  item Json?
+}
+
+type PageoperationsExPage {
+  /// Field referred in an index, but found no data to define the type.
+  id   Json? @map("_id")
+  /// Field referred in an index, but found no data to define the type.
+  path Json?
+}
+
+type PageoperationsPage {
+  /// Field referred in an index, but found no data to define the type.
+  id   Json? @map("_id")
+  /// Field referred in an index, but found no data to define the type.
+  path Json?
+}
+
+type PagesGrantedGroups {
+  /// Field referred in an index, but found no data to define the type.
+  item Json?
+}
+
+model accesstokens {
+  id        String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  expiredAt Json?
+  /// Field referred in an index, but found no data to define the type.
+  tokenHash Json?  @unique(map: "tokenHash_1")
+
+  @@index([expiredAt], map: "expiredAt_1")
+}
+
+model activities {
+  id          String             @id @default(auto()) @map("_id") @db.ObjectId
+  v           Int                @map("__v")
+  action      String
+  createdAt   DateTime           @db.Date
+  endpoint    String
+  ip          String
+  snapshot    ActivitiesSnapshot
+  target      String?            @db.ObjectId
+  targetModel String?
+  user        String?            @db.ObjectId
+
+  @@unique([user, target, action, createdAt], map: "user_1_target_1_action_1_createdAt_1")
+  @@index([user], map: "user_1")
+  @@index([snapshot.username], map: "snapshot.username_1")
+  @@index([target, action], map: "target_1_action_1")
+  @@index([createdAt], map: "createdAt_1")
+}
+
+model aiassistants {
+  id                          String                                   @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  grantedGroupsForAccessScope AiassistantsGrantedGroupsForAccessScope?
+  /// Field referred in an index, but found no data to define the type.
+  grantedGroupsForShareScope  AiassistantsGrantedGroupsForShareScope?
+
+  @@index([grantedGroupsForShareScope.item], map: "grantedGroupsForShareScope.item_1")
+  @@index([grantedGroupsForAccessScope.item], map: "grantedGroupsForAccessScope.item_1")
+}
+
+model attachments {
+  id       String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  creator  Json?
+  /// Field referred in an index, but found no data to define the type.
+  fileName Json?  @unique(map: "fileName_1")
+  /// Field referred in an index, but found no data to define the type.
+  page     Json?
+
+  @@index([page], map: "page_1")
+  @@index([creator], map: "creator_1")
+}
+
+model bookmarkfolders {
+  id    String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  owner Json?
+
+  @@index([owner], map: "owner_1")
+}
+
+model bookmarks {
+  id   String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  page Json?
+  /// Field referred in an index, but found no data to define the type.
+  user Json?
+
+  @@unique([page, user], map: "page_1_user_1")
+  @@index([page], map: "page_1")
+  @@index([user], map: "user_1")
+}
+
+model comments {
+  id       String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  creator  Json?
+  /// Field referred in an index, but found no data to define the type.
+  page     Json?
+  /// Field referred in an index, but found no data to define the type.
+  revision Json?
+
+  @@index([page], map: "page_1")
+  @@index([creator], map: "creator_1")
+  @@index([revision], map: "revision_1")
+}
+
+model configs {
+  id        String   @id @default(auto()) @map("_id") @db.ObjectId
+  v         Int?     @map("__v")
+  createdAt DateTime @db.Date
+  key       String   @unique(map: "key_1")
+  updatedAt DateTime @db.Date
+  value     String
+}
+
+model editorsettings {
+  id String @id @default(auto()) @map("_id") @db.ObjectId
+}
+
+model externalaccounts {
+  id           String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  accountId    Json?
+  /// Field referred in an index, but found no data to define the type.
+  providerType Json?
+
+  @@unique([providerType, accountId], map: "providerType_1_accountId_1")
+}
+
+model externalusergrouprelations {
+  id String @id @default(auto()) @map("_id") @db.ObjectId
+}
+
+model externalusergroups {
+  id         String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  externalId Json?  @unique(map: "externalId_1")
+  /// Field referred in an index, but found no data to define the type.
+  name       Json?
+  /// Field referred in an index, but found no data to define the type.
+  parent     Json?
+  /// Field referred in an index, but found no data to define the type.
+  provider   Json?
+
+  @@unique([name, provider], map: "name_1_provider_1")
+  @@index([parent], map: "parent_1")
+}
+
+model failedemails {
+  id        String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  createdAt Json?
+
+  @@index([createdAt], map: "createdAt_1")
+}
+
+model globalnotificationsettings {
+  id String @id @default(auto()) @map("_id") @db.ObjectId
+}
+
+model growiplugins {
+  id String @id @default(auto()) @map("_id") @db.ObjectId
+}
+
+model inappnotifications {
+  id        String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  action    Json?
+  /// Field referred in an index, but found no data to define the type.
+  createdAt Json?
+  /// Field referred in an index, but found no data to define the type.
+  status    Json?
+  /// Field referred in an index, but found no data to define the type.
+  target    Json?
+  /// Field referred in an index, but found no data to define the type.
+  user      Json?
+
+  @@index([user], map: "user_1")
+  @@index([status], map: "status_1")
+  @@index([createdAt], map: "createdAt_1")
+  @@index([user, target, action, createdAt], map: "user_1_target_1_action_1_createdAt_1")
+}
+
+model inappnotificationsettings {
+  id String @id @default(auto()) @map("_id") @db.ObjectId
+}
+
+model migrations {
+  id        String   @id @default(auto()) @map("_id") @db.ObjectId
+  appliedAt DateTime @db.Date
+  fileName  String
+}
+
+model namedqueries {
+  id            String @id @default(auto()) @map("_id") @db.ObjectId
+  v             Int    @map("__v")
+  /// Could not determine type: the field only had null or empty values in the sample set.
+  creator       Json?
+  delegatorName String
+  name          String @unique(map: "name_1")
+
+  @@index([creator], map: "creator_1")
+}
+
+model pagebulkexportjobs {
+  id String @id @default(auto()) @map("_id") @db.ObjectId
+}
+
+model pagebulkexportpagesnapshots {
+  id String @id @default(auto()) @map("_id") @db.ObjectId
+}
+
+model pageoperations {
+  id          String                @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  actionStage Json?
+  /// Field referred in an index, but found no data to define the type.
+  actionType  Json?
+  /// Field referred in an index, but found no data to define the type.
+  exPage      PageoperationsExPage?
+  /// Field referred in an index, but found no data to define the type.
+  fromPath    Json?
+  /// Field referred in an index, but found no data to define the type.
+  page        PageoperationsPage?
+  /// Field referred in an index, but found no data to define the type.
+  toPath      Json?
+
+  @@index([actionType], map: "actionType_1")
+  @@index([actionStage], map: "actionStage_1")
+  @@index([fromPath], map: "fromPath_1")
+  @@index([toPath], map: "toPath_1")
+  @@index([page.id], map: "page._id_1")
+  @@index([page.path], map: "page.path_1")
+  @@index([exPage.id], map: "exPage._id_1")
+  @@index([exPage.path], map: "exPage.path_1")
+}
+
+model pageredirects {
+  id       String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  fromPath Json?  @unique(map: "fromPath_1")
+}
+
+model pages {
+  id                       String      @id @default(auto()) @map("_id") @db.ObjectId
+  v                        Int         @map("__v")
+  commentCount             Int
+  createdAt                DateTime    @db.Date
+  creator                  String?     @db.ObjectId
+  descendantCount          Int
+  grant                    Int
+  /// Nested objects had no data in the sample dataset to introspect a nested type.
+  grantedGroups            Json?
+  /// Could not determine type: the field only had null or empty values in the sample set.
+  grantedUsers             Json?
+  isEmpty                  Boolean
+  lastUpdateUser           String?     @db.ObjectId
+  latestRevisionBodyLength Int?
+  /// Could not determine type: the field only had null or empty values in the sample set.
+  liker                    Json?
+  parent                   String?     @db.ObjectId
+  path                     String
+  revision                 String?     @db.ObjectId
+  seenUsers                String[]
+  status                   String
+  ttlTimestamp             DateTime?   @db.Date
+  updatedAt                DateTime    @db.Date
+  wip                      Boolean?
+  revisions                revisions[]
+
+  @@index([parent], map: "parent_1")
+  @@index([path], map: "path_1")
+  @@index([status], map: "status_1")
+  @@index([grant], map: "grant_1")
+  @@index([creator], map: "creator_1")
+  @@index([createdAt], map: "createdAt_1")
+  @@index([updatedAt], map: "updatedAt_1")
+  @@index([ttlTimestamp], map: "ttlTimestamp_1")
+}
+
+model pagetagrelations {
+  id            String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  isPageTrashed Json?
+  /// Field referred in an index, but found no data to define the type.
+  relatedPage   Json?
+  /// Field referred in an index, but found no data to define the type.
+  relatedTag    Json?
+
+  @@unique([relatedPage, relatedTag], map: "relatedPage_1_relatedTag_1")
+  @@index([relatedPage], map: "relatedPage_1")
+  @@index([relatedTag], map: "relatedTag_1")
+  @@index([isPageTrashed], map: "isPageTrashed_1")
+}
+
+model passwordresetorders {
+  id    String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  token Json?  @unique(map: "token_1")
+}
+
+model revisions {
+  id        String   @id @default(auto()) @map("_id") @db.ObjectId
+  v         Int      @map("__v")
+  author    String   @db.ObjectId
+  body      String
+  createdAt DateTime @db.Date
+  format    String
+  origin    String?
+  pageId    String   @db.ObjectId
+
+  page pages @relation(fields: [pageId], references: [id])
+
+  @@index([pageId], map: "pageId_1")
+}
+
+model rlflx {
+  id     String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  expire Json?
+  /// Field referred in an index, but found no data to define the type.
+  key    Json?  @unique(map: "key_1")
+
+  @@index([expire(sort: Desc)], map: "expire_-1")
+}
+
+model sessions {
+  id      String   @id @map("_id")
+  expires DateTime @db.Date
+  session String
+
+  @@index([expires], map: "expires_1")
+}
+
+model sharelinks {
+  id          String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  relatedPage Json?
+
+  @@index([relatedPage], map: "relatedPage_1")
+}
+
+model slackappintegrations {
+  id        String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  isPrimary Json?  @unique(map: "isPrimary_1")
+  /// Field referred in an index, but found no data to define the type.
+  tokenGtoP Json?  @unique(map: "tokenGtoP_1")
+  /// Field referred in an index, but found no data to define the type.
+  tokenPtoG Json?  @unique(map: "tokenPtoG_1")
+}
+
+model subscriptions {
+  id   String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  user Json?
+
+  @@index([user], map: "user_1")
+}
+
+model tags {
+  id   String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  name Json?  @unique(map: "name_1")
+}
+
+model threadrelations {
+  id       String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  threadId Json?  @unique(map: "threadId_1")
+}
+
+model transferkeys {
+  id        String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  expireAt  Json?
+  /// Field referred in an index, but found no data to define the type.
+  key       Json?  @unique(map: "key_1")
+  /// Field referred in an index, but found no data to define the type.
+  keyString Json?  @unique(map: "keyString_1")
+
+  @@index([expireAt], map: "expireAt_1")
+}
+
+model updateposts {
+  id      String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  creator Json?
+
+  @@index([creator], map: "creator_1")
+}
+
+model usergrouprelations {
+  id           String   @id @default(auto()) @map("_id") @db.ObjectId
+  v            Int      @map("__v")
+  createdAt    DateTime @db.Date
+  relatedGroup String   @db.ObjectId
+  relatedUser  String   @db.ObjectId
+}
+
+model usergroups {
+  id          String   @id @default(auto()) @map("_id") @db.ObjectId
+  v           Int      @map("__v")
+  createdAt   DateTime @db.Date
+  description String
+  name        String   @unique(map: "name_1")
+  /// Could not determine type: the field only had null or empty values in the sample set.
+  parent      Json?
+  updatedAt   DateTime @db.Date
+
+  @@index([parent], map: "parent_1")
+}
+
+model userregistrationorders {
+  id    String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  token Json?  @unique(map: "token_1")
+}
+
+model users {
+  id                      String   @id @default(auto()) @map("_id") @db.ObjectId
+  v                       Int      @map("__v")
+  admin                   Boolean
+  /// Field referred in an index, but found no data to define the type.
+  apiToken                Json?
+  createdAt               DateTime @db.Date
+  email                   String   @unique(map: "email_1")
+  imageUrlCached          String
+  isEmailPublished        Boolean
+  isGravatarEnabled       Boolean
+  isInvitationEmailSended Boolean
+  lang                    String
+  /// Field referred in an index, but found no data to define the type.
+  lastLoginAt             Json?
+  name                    String
+  password                String
+  readOnly                Boolean
+  /// Field referred in an index, but found no data to define the type.
+  slackMemberId           Json?    @unique(map: "slackMemberId_1")
+  status                  Int
+  updatedAt               DateTime @db.Date
+  username                String   @unique(map: "username_1")
+
+  @@index([name], map: "name_1")
+  @@index([apiToken], map: "apiToken_1")
+  @@index([status], map: "status_1")
+  @@index([lastLoginAt], map: "lastLoginAt_1")
+  @@index([admin], map: "admin_1")
+}
+
+model useruisettings {
+  id   String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  user Json?  @unique(map: "user_1")
+}
+
+model vectorstorefilerelations {
+  id                    String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  attachment            Json?
+  /// Field referred in an index, but found no data to define the type.
+  page                  Json?
+  /// Field referred in an index, but found no data to define the type.
+  vectorStoreRelationId Json?
+
+  @@unique([vectorStoreRelationId, page, attachment], map: "vectorStoreRelationId_1_page_1_attachment_1")
+}
+
+model vectorstores {
+  id            String @id @default(auto()) @map("_id") @db.ObjectId
+  /// Field referred in an index, but found no data to define the type.
+  vectorStoreId Json?  @unique(map: "vectorStoreId_1")
+}
+
+model yjs_writings {
+  id      String  @id @default(auto()) @map("_id") @db.ObjectId
+  action  String?
+  clock   Int?
+  docName String
+  metaKey String?
+  /// Field referred in an index, but found no data to define the type.
+  part    Json?
+  /// Multiple data types found: Float: 33.3%, Binary: 66.7% out of 3 sampled entries
+  value   Json
+  version String
+
+  @@index([version, docName, action, clock, part], map: "version_1_docName_1_action_1_clock_1_part_1")
+  @@index([version, docName, metaKey], map: "version_1_docName_1_metaKey_1")
+  @@index([docName, clock], map: "docName_1_clock_1")
+  @@map("yjs-writings")
+}

+ 6 - 0
apps/app/prisma/types.ts

@@ -0,0 +1,6 @@
+import type { PrismaClient } from '~/generated/prisma/client';
+
+/**
+ * Migration function type
+ */
+export type Migration = (args: { context: PrismaClient }) => Promise<void>;

+ 4 - 0
apps/app/resource/Contributor.js

@@ -160,6 +160,10 @@ const contributors = [
             name: 'Yuji Tounai',
             name: 'Yuji Tounai',
           },
           },
           { name: 'yy0931' },
           { name: 'yy0931' },
+          {
+            position: 'GMO Cybersecurity by Ierae, Inc.',
+            name: 'Sho Odagiri',
+          },
         ],
         ],
       },
       },
     ],
     ],

+ 4 - 2
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -47,7 +47,7 @@ import {
   isUploadEnabledAtom,
   isUploadEnabledAtom,
 } from '~/states/server-configurations';
 } from '~/states/server-configurations';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
-import { useEditorMode } from '~/states/ui/editor';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import {
 import {
   PageAccessoriesModalContents,
   PageAccessoriesModalContents,
   usePageAccessoriesModalActions,
   usePageAccessoriesModalActions,
@@ -323,7 +323,7 @@ const GrowiContextualSubNavigation = (
   const revisionId =
   const revisionId =
     revision != null && isPopulated(revision) ? revision._id : undefined;
     revision != null && isPopulated(revision) ? revision._id : undefined;
 
 
-  const { editorMode } = useEditorMode();
+  const { editorMode, setEditorMode } = useEditorMode();
   const pageId = useCurrentPageId(true);
   const pageId = useCurrentPageId(true);
   const currentUser = useCurrentUser();
   const currentUser = useCurrentUser();
   const isGuestUser = useIsGuestUser();
   const isGuestUser = useIsGuestUser();
@@ -390,6 +390,7 @@ const GrowiContextualSubNavigation = (
 
 
         if (isCompletely) {
         if (isCompletely) {
           // redirect to NotFound Page
           // redirect to NotFound Page
+          setEditorMode(EditorMode.View);
           router.push(path);
           router.push(path);
         } else if (currentPathname != null) {
         } else if (currentPathname != null) {
           router.push(currentPathname);
           router.push(currentPathname);
@@ -408,6 +409,7 @@ const GrowiContextualSubNavigation = (
       openDeleteModal,
       openDeleteModal,
       router,
       router,
       mutatePageInfo,
       mutatePageInfo,
+      setEditorMode,
     ],
     ],
   );
   );
 
 

+ 1 - 5
apps/app/src/client/components/Page/SlideRenderer.tsx

@@ -1,5 +1,4 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
-import type { Options as ReactMarkdownOptions } from 'react-markdown';
 
 
 import { usePresentationViewOptions } from '~/stores/renderer';
 import { usePresentationViewOptions } from '~/stores/renderer';
 
 
@@ -16,10 +15,7 @@ export const SlideRenderer = (props: SlideRendererProps): JSX.Element => {
   const { data: rendererOptions } = usePresentationViewOptions();
   const { data: rendererOptions } = usePresentationViewOptions();
 
 
   return (
   return (
-    <Slides
-      hasMarpFlag={marp}
-      options={{ rendererOptions: rendererOptions as ReactMarkdownOptions }}
-    >
+    <Slides hasMarpFlag={marp} options={{ rendererOptions }}>
       {markdown}
       {markdown}
     </Slides>
     </Slides>
   );
   );

+ 10 - 2
apps/app/src/client/components/PageComment/Comment.module.scss

@@ -41,8 +41,16 @@
     // comment body
     // comment body
     :global(.page-comment-body) {
     :global(.page-comment-body) {
       word-wrap: break-word;
       word-wrap: break-word;
-      :global(.wiki p) {
-        margin: 8px 0;
+      :global(.wiki) {
+        p {
+          margin: 8px 0;
+        }
+        blockquote {
+          margin: 24px 0 8px;
+          &:first-child {
+            margin-top: 0;
+          }
+        }
       }
       }
     }
     }
 
 

+ 1 - 4
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.module.scss

@@ -4,7 +4,4 @@
   @extend %user-list-popover;
   @extend %user-list-popover;
 }
 }
 
 
-.avatar-wrapper {
-  // Collapse inline-element ghost space inside the flex container
-  line-height: 0;
-}
+

+ 26 - 14
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.spec.tsx

@@ -18,20 +18,29 @@ import userEvent from '@testing-library/user-event';
 vi.mock('@growi/ui/dist/components', () => ({
 vi.mock('@growi/ui/dist/components', () => ({
   UserPicture: ({
   UserPicture: ({
     user,
     user,
-    className,
     noTooltip,
     noTooltip,
+    testId,
+    rootClassName,
+    rootStyle,
+    onClick,
   }: {
   }: {
     user: EditingClient;
     user: EditingClient;
-    className?: string;
     noTooltip?: boolean;
     noTooltip?: boolean;
+    testId?: string;
+    rootClassName?: string;
+    rootStyle?: Record<string, string>;
+    onClick?: () => void;
   }) => (
   }) => (
-    <span
-      data-testid={`user-picture-${user.clientId}`}
+    <button
+      type="button"
+      data-testid={testId ?? `user-picture-${user.clientId}`}
       data-no-tooltip={noTooltip ? 'true' : undefined}
       data-no-tooltip={noTooltip ? 'true' : undefined}
-      className={className}
+      className={rootClassName}
+      style={rootStyle}
+      onClick={onClick}
     >
     >
       {user.name}
       {user.name}
-    </span>
+    </button>
   ),
   ),
 }));
 }));
 
 
@@ -212,15 +221,19 @@ describe('EditingUserList — Task 16.1', () => {
 /**
 /**
  * Task 20.4 — EditingUserList tooltip integration
  * Task 20.4 — EditingUserList tooltip integration
  * Requirements: 7.2
  * Requirements: 7.2
+ *
+ * Tooltip is provided by UserPicture's built-in UncontrolledTooltip.
+ * noTooltip must NOT be passed so the tooltip renders for all avatars.
+ * The testId prop routes the data-testid onto UserPicture's root element,
+ * which is the tooltip target — click and tooltip coexist on the same node.
  */
  */
 describe('EditingUserList — Task 20.4 (tooltip integration)', () => {
 describe('EditingUserList — Task 20.4 (tooltip integration)', () => {
   describe('Req 7.2 — UserPicture rendered without noTooltip so tooltip is active', () => {
   describe('Req 7.2 — UserPicture rendered without noTooltip so tooltip is active', () => {
     it('does not pass noTooltip to UserPicture for direct avatars', () => {
     it('does not pass noTooltip to UserPicture for direct avatars', () => {
       render(<EditingUserList clientList={[clientAlice]} />);
       render(<EditingUserList clientList={[clientAlice]} />);
 
 
-      const pic = screen.getByTestId('user-picture-1');
-      // data-no-tooltip attribute is only set when noTooltip=true; should be absent
-      expect(pic.getAttribute('data-no-tooltip')).toBeNull();
+      const wrapper = screen.getByTestId('avatar-wrapper-1');
+      expect(wrapper.getAttribute('data-no-tooltip')).toBeNull();
     });
     });
 
 
     it('does not pass noTooltip to UserPicture for all first-4 direct avatars', () => {
     it('does not pass noTooltip to UserPicture for all first-4 direct avatars', () => {
@@ -231,8 +244,8 @@ describe('EditingUserList — Task 20.4 (tooltip integration)', () => {
       );
       );
 
 
       for (const client of [clientAlice, clientBob, clientCarol, clientDave]) {
       for (const client of [clientAlice, clientBob, clientCarol, clientDave]) {
-        const pic = screen.getByTestId(`user-picture-${client.clientId}`);
-        expect(pic.getAttribute('data-no-tooltip')).toBeNull();
+        const wrapper = screen.getByTestId(`avatar-wrapper-${client.clientId}`);
+        expect(wrapper.getAttribute('data-no-tooltip')).toBeNull();
       }
       }
     });
     });
 
 
@@ -249,11 +262,10 @@ describe('EditingUserList — Task 20.4 (tooltip integration)', () => {
         />,
         />,
       );
       );
 
 
-      // Open the overflow popover
       await userEvent.click(screen.getByRole('button', { name: /^\+1$/ }));
       await userEvent.click(screen.getByRole('button', { name: /^\+1$/ }));
 
 
-      const evePic = screen.getByTestId('user-picture-5');
-      expect(evePic.getAttribute('data-no-tooltip')).toBeNull();
+      const eveWrapper = screen.getByTestId('avatar-wrapper-5');
+      expect(eveWrapper.getAttribute('data-no-tooltip')).toBeNull();
     });
     });
   });
   });
 });
 });

+ 49 - 51
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -6,7 +6,6 @@ import { Popover, PopoverBody } from 'reactstrap';
 import styles from './EditingUserList.module.scss';
 import styles from './EditingUserList.module.scss';
 
 
 const userListPopoverClass = styles['user-list-popover'] ?? '';
 const userListPopoverClass = styles['user-list-popover'] ?? '';
-const avatarWrapperClass = styles['avatar-wrapper'] ?? '';
 
 
 type Props = {
 type Props = {
   clientList: EditingClient[];
   clientList: EditingClient[];
@@ -18,15 +17,16 @@ const AvatarWrapper: FC<{
   onUserClick?: (clientId: number) => void;
   onUserClick?: (clientId: number) => void;
 }> = ({ client, onUserClick }) => {
 }> = ({ client, onUserClick }) => {
   return (
   return (
-    <button
-      type="button"
-      data-testid={`avatar-wrapper-${client.clientId}`}
-      className={`${avatarWrapperClass} d-inline-flex align-items-center justify-content-center p-0 bg-transparent rounded-circle`}
-      style={{ border: `2px solid ${client.color}` }}
-      onClick={() => onUserClick?.(client.clientId)}
-    >
-      <UserPicture user={client} noLink />
-    </button>
+    <UserPicture
+      user={client}
+      noLink
+      testId={`avatar-wrapper-${client.clientId}`}
+      rootClassName="d-flex rounded-circle"
+      rootStyle={{ border: `2px solid ${client.color}` }}
+      onClick={
+        onUserClick != null ? () => onUserClick(client.clientId) : undefined
+      }
+    />
   );
   );
 };
 };
 
 
@@ -44,48 +44,46 @@ export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
   }
   }
 
 
   return (
   return (
-    <div className="d-flex flex-column justify-content-start justify-content-sm-end">
-      <div className="d-flex justify-content-start justify-content-sm-end">
-        {firstFourUsers.map((editingClient) => (
-          <div key={editingClient.clientId} className="ms-1">
-            <AvatarWrapper client={editingClient} onUserClick={onUserClick} />
-          </div>
-        ))}
+    <div className="d-flex">
+      {firstFourUsers.map((editingClient) => (
+        <div key={editingClient.clientId} className="ms-1">
+          <AvatarWrapper client={editingClient} onUserClick={onUserClick} />
+        </div>
+      ))}
 
 
-        {remainingUsers.length > 0 && (
-          <div className="ms-1">
-            <button
-              type="button"
-              ref={popoverTargetRef}
-              className="btn border-0 bg-info-subtle rounded-pill p-0"
-              onClick={togglePopover}
-            >
-              <span className="fw-bold text-info p-1">
-                +{remainingUsers.length}
-              </span>
-            </button>
-            <Popover
-              placement="bottom"
-              isOpen={isPopoverOpen}
-              target={popoverTargetRef}
-              toggle={togglePopover}
-              trigger="legacy"
-            >
-              <PopoverBody className={userListPopoverClass}>
-                <div className="d-flex flex-wrap gap-1">
-                  {remainingUsers.map((editingClient) => (
-                    <AvatarWrapper
-                      key={editingClient.clientId}
-                      client={editingClient}
-                      onUserClick={onUserClick}
-                    />
-                  ))}
-                </div>
-              </PopoverBody>
-            </Popover>
-          </div>
-        )}
-      </div>
+      {remainingUsers.length > 0 && (
+        <div className="ms-1">
+          <button
+            type="button"
+            ref={popoverTargetRef}
+            className="btn border-0 bg-info-subtle rounded-pill p-0"
+            onClick={togglePopover}
+          >
+            <span className="fw-bold text-info p-1">
+              +{remainingUsers.length}
+            </span>
+          </button>
+          <Popover
+            placement="bottom"
+            isOpen={isPopoverOpen}
+            target={popoverTargetRef}
+            toggle={togglePopover}
+            trigger="legacy"
+          >
+            <PopoverBody className={userListPopoverClass}>
+              <div className="d-flex flex-wrap gap-1">
+                {remainingUsers.map((editingClient) => (
+                  <AvatarWrapper
+                    key={editingClient.clientId}
+                    client={editingClient}
+                    onUserClick={onUserClick}
+                  />
+                ))}
+              </div>
+            </PopoverBody>
+          </Popover>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 };
 };

+ 14 - 3
apps/app/src/client/components/PageList/PageListItemL.tsx

@@ -238,11 +238,22 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (
 
 
   return (
   return (
     <li key={pageData._id}>
     <li key={pageData._id}>
-      <button
-        type="button"
+      {/* biome-ignore lint/a11y/useSemanticElements: cannot use <button> here because PageItemControl renders a nested <button> (DropdownToggle) */}
+      <div
+        role="button"
+        tabIndex={0}
         className={`list-group-item d-flex align-items-center px-3 px-md-1 text-start w-100 ${styleListGroupItem} ${styleActive}`}
         className={`list-group-item d-flex align-items-center px-3 px-md-1 text-start w-100 ${styleListGroupItem} ${styleActive}`}
         data-testid="page-list-item-L"
         data-testid="page-list-item-L"
         onClick={clickHandler}
         onClick={clickHandler}
+        onKeyDown={(e) => {
+          if (
+            e.target === e.currentTarget &&
+            (e.key === 'Enter' || e.key === ' ')
+          ) {
+            e.preventDefault();
+            clickHandler();
+          }
+        }}
       >
       >
         <div className="text-break w-100">
         <div className="text-break w-100">
           <div className="d-flex">
           <div className="d-flex">
@@ -365,7 +376,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (
           </div>
           </div>
         </div>
         </div>
         {/* TODO: adjust snippet position */}
         {/* TODO: adjust snippet position */}
-      </button>
+      </div>
     </li>
     </li>
   );
   );
 };
 };

+ 1 - 2
apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx

@@ -5,7 +5,6 @@ import type { PresentationProps } from '@growi/presentation/dist/client';
 import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useFullScreen } from '@growi/ui/dist/utils';
 import { useFullScreen } from '@growi/ui/dist/utils';
-import type { Options as ReactMarkdownOptions } from 'react-markdown';
 import { Modal, ModalBody } from 'reactstrap';
 import { Modal, ModalBody } from 'reactstrap';
 
 
 import { useCurrentPageData } from '~/states/page';
 import { useCurrentPageData } from '~/states/page';
@@ -87,7 +86,7 @@ const PagePresentationModalSubstance: React.FC = () => {
         {rendererOptions != null && isEnabledMarp != null && (
         {rendererOptions != null && isEnabledMarp != null && (
           <Presentation
           <Presentation
             options={{
             options={{
-              rendererOptions: rendererOptions as ReactMarkdownOptions,
+              rendererOptions,
               revealOptions: {
               revealOptions: {
                 embedded: true,
                 embedded: true,
                 hash: true,
                 hash: true,

+ 1 - 0
apps/app/src/features/page/index.ts

@@ -0,0 +1 @@
+export * from './models';

+ 1 - 0
apps/app/src/features/page/models/index.ts

@@ -0,0 +1 @@
+export * from './revision';

+ 5 - 0
apps/app/src/features/page/models/revision.ts

@@ -0,0 +1,5 @@
+import { Prisma } from '~/generated/prisma/client';
+
+export const extension = Prisma.defineExtension((client) =>
+  client.$extends({}),
+);

+ 13 - 2
apps/app/src/server/routes/apiv3/page/index.ts

@@ -1201,13 +1201,24 @@ module.exports = (crowi: Crowi) => {
       );
       );
 
 
       try {
       try {
+        const count = await Page.countByIdAndViewer(pageId, req.user);
+        if (count === 0) {
+          return res.apiv3Err(
+            new ErrorV3(
+              'Page is unreachable or empty.',
+              'page_unreachable_or_empty',
+            ),
+            400,
+          );
+        }
+
         const updateQuery =
         const updateQuery =
           expandContentWidth === isContainerFluidBySystem
           expandContentWidth === isContainerFluidBySystem
             ? { $unset: { expandContentWidth } } // remove if the specified value is the same to the system's one
             ? { $unset: { expandContentWidth } } // remove if the specified value is the same to the system's one
             : { $set: { expandContentWidth } };
             : { $set: { expandContentWidth } };
 
 
-        const page = await Page.updateOne({ _id: pageId }, updateQuery);
-        return res.apiv3({ page });
+        await Page.updateOne({ _id: pageId }, updateQuery);
+        return res.apiv3({});
       } catch (err) {
       } catch (err) {
         logger.error('update-content-width-failed', err);
         logger.error('update-content-width-failed', err);
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);

+ 8 - 2
apps/app/src/server/routes/apiv3/page/publish-page.ts

@@ -45,9 +45,15 @@ export const publishPageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
       const { pageId } = req.params;
       const { pageId } = req.params;
 
 
       try {
       try {
-        const page = await Page.findById(pageId);
+        const page = await Page.findByIdAndViewer(pageId, req.user);
         if (page == null) {
         if (page == null) {
-          return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
+          return res.apiv3Err(
+            new ErrorV3(
+              'Page is unreachable or empty.',
+              'page_unreachable_or_empty',
+            ),
+            400,
+          );
         }
         }
 
 
         page.publish();
         page.publish();

+ 8 - 2
apps/app/src/server/routes/apiv3/page/unpublish-page.ts

@@ -47,9 +47,15 @@ export const unpublishPageHandlersFactory = (
       const { pageId } = req.params;
       const { pageId } = req.params;
 
 
       try {
       try {
-        const page = await Page.findById(pageId);
+        const page = await Page.findByIdAndViewer(pageId, req.user);
         if (page == null) {
         if (page == null) {
-          return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
+          return res.apiv3Err(
+            new ErrorV3(
+              'Page is unreachable or empty.',
+              'page_unreachable_or_empty',
+            ),
+            400,
+          );
         }
         }
 
 
         page.unpublish();
         page.unpublish();

+ 5 - 1
apps/app/src/styles/organisms/_wiki.scss

@@ -123,11 +123,15 @@
 
 
   blockquote {
   blockquote {
     padding: 0 20px;
     padding: 0 20px;
-    margin: 0 0 30px;
+    margin: 24px 0 16px;
     font-size: 0.9em;
     font-size: 0.9em;
     /* stylelint-disable-next-line scss/no-global-function-names */
     /* stylelint-disable-next-line scss/no-global-function-names */
     color: lighten(bs.$gray-800, 35%);
     color: lighten(bs.$gray-800, 35%);
     border-left: 0.3rem solid #ddd;
     border-left: 0.3rem solid #ddd;
+
+    &:first-child {
+      margin-top: 0;
+    }
   }
   }
 
 
   img,video {
   img,video {

+ 5 - 0
apps/app/src/utils/prisma.ts

@@ -0,0 +1,5 @@
+import { extension as RevisionExtension } from '~/features/page';
+import { PrismaClient as OriginalPrismaClient } from '~/generated/prisma/client';
+
+export const prisma = new OriginalPrismaClient().$extends(RevisionExtension);
+export type PrismaClient = typeof prisma;

+ 1 - 1
apps/app/tsconfig.json

@@ -26,7 +26,7 @@
       { "transform": "typescript-transform-paths", "afterDeclarations": true }
       { "transform": "typescript-transform-paths", "afterDeclarations": true }
     ]
     ]
   },
   },
-  "include": ["next-env.d.ts", "config", "src"],
+  "include": ["next-env.d.ts", "config", "prisma", "src"],
   "exclude": ["src/**/*.vendor-styles.*"],
   "exclude": ["src/**/*.vendor-styles.*"],
   "ts-node": {
   "ts-node": {
     "transpileOnly": true,
     "transpileOnly": true,

+ 6 - 1
apps/app/turbo.json

@@ -23,8 +23,13 @@
       ],
       ],
       "outputLogs": "new-only"
       "outputLogs": "new-only"
     },
     },
+    "prisma:generate": {
+      "outputs": ["src/generated/prisma/**"],
+      "inputs": ["prisma/schema.prisma"],
+      "outputLogs": "new-only"
+    },
     "build": {
     "build": {
-      "dependsOn": ["^build", "pre:styles-commons", "pre:styles-components"],
+      "dependsOn": ["^build", "pre:styles-commons", "pre:styles-components", "prisma:generate"],
       "outputs": [".next/**", "!.next/cache/**", "dist/**"],
       "outputs": [".next/**", "!.next/cache/**", "dist/**"],
       "inputs": [
       "inputs": [
         "next.config.ts",
         "next.config.ts",

+ 10 - 18
apps/pdf-converter/docker/Dockerfile

@@ -2,7 +2,6 @@
 
 
 ARG NODE_VERSION=24
 ARG NODE_VERSION=24
 ARG OPT_DIR="/opt"
 ARG OPT_DIR="/opt"
-ARG PNPM_HOME="/root/.local/share/pnpm"
 
 
 ##
 ##
 ## base
 ## base
@@ -10,23 +9,19 @@ ARG PNPM_HOME="/root/.local/share/pnpm"
 FROM node:${NODE_VERSION}-slim AS base
 FROM node:${NODE_VERSION}-slim AS base
 
 
 ARG OPT_DIR
 ARG OPT_DIR
-ARG PNPM_HOME
 
 
 WORKDIR $OPT_DIR
 WORKDIR $OPT_DIR
 
 
-# install tools
-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 --no-install-recommends
-
-# install pnpm
-RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.32.1" sh -
-ENV PNPM_HOME=$PNPM_HOME
-ENV PATH="$PNPM_HOME:$PATH"
+# Activate corepack so the pnpm version pinned in the workspace package.json
+# "packageManager" field is used (avoids drift between Dockerfile and local/CI).
+RUN corepack enable
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME/bin:$PATH"
 
 
 # install turbo
 # install turbo
-RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
-  pnpm add turbo --global
+# Note: no cache mount here — pnpm global install links binaries into the store,
+# and the BuildKit cache mount would be unmounted after RUN, breaking those links.
+RUN pnpm add turbo --global
 
 
 
 
 
 
@@ -35,15 +30,12 @@ RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
 ##
 ##
 FROM base AS builder
 FROM base AS builder
 
 
-ENV PNPM_HOME=$PNPM_HOME
-ENV PATH="$PNPM_HOME:$PATH"
-
 WORKDIR $OPT_DIR
 WORKDIR $OPT_DIR
 
 
 COPY . .
 COPY . .
 
 
-RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
-  pnpm install ---frozen-lockfile
+RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
+  pnpm install --frozen-lockfile --ignore-scripts
 
 
 # build
 # build
 RUN turbo run clean
 RUN turbo run clean

+ 10 - 0
apps/pdf-converter/docker/Dockerfile.dockerignore

@@ -0,0 +1,10 @@
+**/node_modules
+**/dist
+**/coverage
+**/Dockerfile
+**/*.dockerignore
+**/.pnpm-store
+**/.turbo
+out
+apps/**
+!apps/pdf-converter

+ 2 - 2
apps/pdf-converter/package.json

@@ -16,8 +16,8 @@
     "lint": "run-p lint:**",
     "lint": "run-p lint:**",
     "gen:swagger-spec": "SKIP_PUPPETEER_INIT=true node --import @swc-node/register/esm-register src/bin/index.ts generate-swagger --output ./specs",
     "gen:swagger-spec": "SKIP_PUPPETEER_INIT=true node --import @swc-node/register/esm-register src/bin/index.ts generate-swagger --output ./specs",
     "build": "pnpm tsc -p tsconfig.build.json",
     "build": "pnpm tsc -p tsconfig.build.json",
-    "version:prerelease": "pnpm version prerelease --preid=RC",
-    "version:prepatch": "pnpm version prepatch --preid=RC",
+    "version:prerelease": "pnpm version prerelease --preid=RC --no-git-tag-version --no-git-checks",
+    "version:prepatch": "pnpm version prepatch --preid=RC --no-git-tag-version --no-git-checks",
     "test": "SKIP_PUPPETEER_INIT=true vitest run"
     "test": "SKIP_PUPPETEER_INIT=true vitest run"
   },
   },
   "dependencies": {
   "dependencies": {

+ 9 - 7
apps/slackbot-proxy/docker/Dockerfile

@@ -11,13 +11,15 @@ ENV optDir="/opt"
 
 
 WORKDIR ${optDir}
 WORKDIR ${optDir}
 
 
-# install pnpm
-RUN apt-get update && apt-get install -y ca-certificates wget --no-install-recommends \
-  && wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.32.1" sh -
-ENV PNPM_HOME="/root/.local/share/pnpm"
-ENV PATH="$PNPM_HOME:$PATH"
+# Activate corepack so the pnpm version pinned in the workspace package.json
+# "packageManager" field is used (avoids drift between Dockerfile and local/CI).
+RUN corepack enable
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME/bin:$PATH"
 
 
 # install turbo
 # install turbo
+# Note: no cache mount here — pnpm global install links binaries into the store,
+# and the BuildKit cache mount would be unmounted after RUN, breaking those links.
 RUN pnpm add turbo --global
 RUN pnpm add turbo --global
 
 
 
 
@@ -33,8 +35,8 @@ WORKDIR ${optDir}
 
 
 COPY . .
 COPY . .
 
 
-RUN pnpm add node-gyp --global
-RUN pnpm install ---frozen-lockfile
+RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
+  pnpm install --frozen-lockfile --ignore-scripts
 
 
 # build
 # build
 RUN turbo run build --filter @growi/slackbot-proxy
 RUN turbo run build --filter @growi/slackbot-proxy

+ 2 - 1
apps/slackbot-proxy/docker/Dockerfile.dockerignore

@@ -6,4 +6,5 @@
 **/.pnpm-store
 **/.pnpm-store
 **/.turbo
 **/.turbo
 out
 out
-apps/app
+apps/**
+!apps/slackbot-proxy

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "7.5.2-slackbot-proxy.0",
+  "version": "7.5.3-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
@@ -21,11 +21,11 @@
     "lint:typecheck": "tsc --noEmit",
     "lint:typecheck": "tsc --noEmit",
     "lint": "run-p lint:*",
     "lint": "run-p lint:*",
     "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
     "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
-    "version:patch": "pnpm version patch",
-    "version:prerelease": "pnpm version prerelease --preid=slackbot-proxy",
-    "version:prepatch": "pnpm version prepatch --preid=slackbot-proxy",
-    "version:preminor": "pnpm version preminor --preid=slackbot-proxy",
-    "version:premajor": "pnpm version premajor --preid=slackbot-proxy"
+    "version:patch": "pnpm version patch --no-git-tag-version --no-git-checks",
+    "version:prerelease": "pnpm version prerelease --preid=slackbot-proxy --no-git-tag-version --no-git-checks",
+    "version:prepatch": "pnpm version prepatch --preid=slackbot-proxy --no-git-tag-version --no-git-checks",
+    "version:preminor": "pnpm version preminor --preid=slackbot-proxy --no-git-tag-version --no-git-checks",
+    "version:premajor": "pnpm version premajor --preid=slackbot-proxy --no-git-tag-version --no-git-checks"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
     "@tsed/*": "v6.133.1 causes 'TypeError: Cannot read properties of undefined (reading 'prototype')' with `@Middleware()`",
     "@tsed/*": "v6.133.1 causes 'TypeError: Cannot read properties of undefined (reading 'prototype')' with `@Middleware()`",

+ 1 - 0
biome.json

@@ -17,6 +17,7 @@
       "!.vscode",
       "!.vscode",
       "!.claude",
       "!.claude",
       "!tsconfig.base.json",
       "!tsconfig.base.json",
+      "!apps/app/src/generated",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs"
       "!packages/pdf-converter-client/specs"
     ]
     ]

+ 2 - 44
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "7.5.2",
+  "version": "7.5.3-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "license": "MIT",
   "private": true,
   "private": true,
@@ -20,7 +20,7 @@
   "bugs": {
   "bugs": {
     "url": "https://github.com/growilabs/growi/issues"
     "url": "https://github.com/growilabs/growi/issues"
   },
   },
-  "packageManager": "pnpm@10.32.1",
+  "packageManager": "pnpm@11.1.1",
   "scripts": {
   "scripts": {
     "bootstrap": "pnpm install",
     "bootstrap": "pnpm install",
     "start": "pnpm run app:server",
     "start": "pnpm run app:server",
@@ -87,48 +87,6 @@
     "vitest": "^3.2.4",
     "vitest": "^3.2.4",
     "vitest-mock-extended": "^3.1.0"
     "vitest-mock-extended": "^3.1.0"
   },
   },
-  "// comments for pnpm.overrides": {
-    "@lykmapipo/common>flat": "flat v6 is provided only by ESM, but @lykmapipo/common requires CommonJS version",
-    "@lykmapipo/common>mime": "mime v4 is provided only by ESM, but @lykmapipo/common requires CommonJS version",
-    "@lykmapipo/common>parse-json": "parse-json v6 is provided only by ESM, but @lykmapipo/common requires CommonJS version",
-    "axios": "CVE-2025-XXXXX: CRLF Injection + Prototype Pollution combo leads to HTTP Request Smuggling (CVSS 10.0). All versions < 1.15.0 are vulnerable."
-  },
-  "// comments for pnpm.packageExtensions": {
-    "@orval/core": "@orval/core bundles @stoplight/json-ref-resolver which requires lodash/get at runtime, but @orval/core does not declare lodash as a dependency"
-  },
-  "pnpm": {
-    "overrides": {
-      "@lykmapipo/common>flat": "5.0.2",
-      "@lykmapipo/common>mime": "3.0.0",
-      "@lykmapipo/common>parse-json": "5.2.0",
-      "axios": "^1.15.0"
-    },
-    "packageExtensions": {
-      "@orval/core": {
-        "dependencies": {
-          "lodash": "*"
-        }
-      }
-    },
-    "ignoredBuiltDependencies": [
-      "@swc/core",
-      "core-js",
-      "esbuild",
-      "leveldown",
-      "protobufjs",
-      "puppeteer",
-      "ttf2woff2"
-    ],
-    "onlyBuiltDependencies": [
-      "lefthook"
-    ],
-    "// comments for patchedDependencies": {
-      "@marp-team/marp-core": "The patch excludes mathjax-full from the dependency graph of Marp Core."
-    },
-    "patchedDependencies": {
-      "@marp-team/marp-core": "packages/presentation/patches/@marp-team__marp-core.patch"
-    }
-  },
   "engines": {
   "engines": {
     "node": "^24"
     "node": "^24"
   }
   }

+ 1 - 1
packages/core/package.json

@@ -79,7 +79,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/express": "^4",
     "@types/express": "^4",
-    "mongoose": "^6.13.6",
+    "mongoose": "^6.13.9",
     "socket.io-client": "^4.7.5",
     "socket.io-client": "^4.7.5",
     "swr": "^2.2.2"
     "swr": "^2.2.2"
   }
   }

+ 2 - 1
packages/presentation/package.json

@@ -37,7 +37,8 @@
     "lint:biome": "biome check",
     "lint:biome": "biome check",
     "lint:styles": "stylelint --allow-empty-input \"src/**/*.scss\" \"src/**/*.css\"",
     "lint:styles": "stylelint --allow-empty-input \"src/**/*.scss\" \"src/**/*.css\"",
     "lint:typecheck": "tsgo --noEmit",
     "lint:typecheck": "tsgo --noEmit",
-    "lint": "run-p lint:*"
+    "lint": "run-p lint:*",
+    "test": "vitest run"
   },
   },
   "dependencies": {
   "dependencies": {
   },
   },

+ 58 - 0
packages/presentation/src/client/components/GrowiSlides.spec.tsx

@@ -0,0 +1,58 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { GrowiSlides } from './GrowiSlides';
+
+vi.mock('next/head', () => ({
+  default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+}));
+
+vi.mock('../consts/marpit-base-css.vendor-styles.prebuilt', () => ({
+  PRESENTATION_MARPIT_CSS: '',
+  SLIDE_MARPIT_CSS: '',
+}));
+
+vi.mock('../services/renderer/extract-sections', () => ({
+  remarkPlugin: vi.fn(),
+}));
+
+vi.mock('./RichSlideSection', () => ({
+  RichSlideSection: () => <div />,
+  PresentationRichSlideSection: () => <div />,
+}));
+
+const validRendererOptions = {
+  remarkPlugins: [],
+  rehypePlugins: [],
+  components: {},
+};
+
+describe('GrowiSlides', () => {
+  it('does not throw when rendererOptions is undefined', () => {
+    expect(() =>
+      render(
+        <GrowiSlides options={{ rendererOptions: undefined }}>
+          {'# Slide'}
+        </GrowiSlides>,
+      ),
+    ).not.toThrow();
+  });
+
+  it('renders nothing when rendererOptions is undefined', () => {
+    const { container } = render(
+      <GrowiSlides options={{ rendererOptions: undefined }}>
+        {'# Slide'}
+      </GrowiSlides>,
+    );
+    expect(container.firstChild).toBeNull();
+  });
+
+  it('renders slides content when rendererOptions is valid', () => {
+    render(
+      <GrowiSlides options={{ rendererOptions: validRendererOptions }}>
+        {'# Slide 1'}
+      </GrowiSlides>,
+    );
+    expect(screen.queryByText('Slide 1')).toBeTruthy();
+  });
+});

+ 30 - 18
packages/presentation/src/client/components/GrowiSlides.tsx

@@ -1,6 +1,7 @@
-import type { JSX } from 'react';
+import { type JSX, useMemo } from 'react';
 import Head from 'next/head';
 import Head from 'next/head';
 import ReactMarkdown from 'react-markdown';
 import ReactMarkdown from 'react-markdown';
+import type { PluggableList } from 'unified';
 
 
 import { MARP_CONTAINER_CLASS_NAME, type PresentationOptions } from '../consts';
 import { MARP_CONTAINER_CLASS_NAME, type PresentationOptions } from '../consts';
 import {
 import {
@@ -23,25 +24,36 @@ export const GrowiSlides = (props: Props): JSX.Element => {
   const { options, children, presentation } = props;
   const { options, children, presentation } = props;
   const { rendererOptions, isDarkMode, disableSeparationByHeader } = options;
   const { rendererOptions, isDarkMode, disableSeparationByHeader } = options;
 
 
-  if (
-    rendererOptions.remarkPlugins == null ||
-    rendererOptions.components == null
-  ) {
-    // biome-ignore lint/complexity/noUselessFragments: This is for type checking only. The actual code will never reach here.
+  // Derive a new options object instead of mutating `rendererOptions`:
+  // it is a shared SWR cache reference also consumed by PagePresentationModal,
+  // so mutation here would leak into other components and accumulate on re-render.
+  const slideRendererOptions = useMemo(() => {
+    if (
+      rendererOptions == null ||
+      rendererOptions.remarkPlugins == null ||
+      rendererOptions.components == null
+    ) {
+      return null;
+    }
+    const remarkPlugins: PluggableList = [
+      ...rendererOptions.remarkPlugins,
+      [extractSections.remarkPlugin, { isDarkMode, disableSeparationByHeader }],
+    ];
+    return {
+      ...rendererOptions,
+      remarkPlugins,
+      components: {
+        ...rendererOptions.components,
+        section: presentation ? PresentationRichSlideSection : RichSlideSection,
+      },
+    };
+  }, [rendererOptions, isDarkMode, disableSeparationByHeader, presentation]);
+
+  if (slideRendererOptions == null) {
+    // biome-ignore lint/complexity/noUselessFragments: early return when rendererOptions is null
     return <></>;
     return <></>;
   }
   }
 
 
-  rendererOptions.remarkPlugins.push([
-    extractSections.remarkPlugin,
-    {
-      isDarkMode,
-      disableSeparationByHeader,
-    },
-  ]);
-  rendererOptions.components.section = presentation
-    ? PresentationRichSlideSection
-    : RichSlideSection;
-
   const css = presentation ? PRESENTATION_MARPIT_CSS : SLIDE_MARPIT_CSS;
   const css = presentation ? PRESENTATION_MARPIT_CSS : SLIDE_MARPIT_CSS;
   return (
   return (
     <>
     <>
@@ -49,7 +61,7 @@ export const GrowiSlides = (props: Props): JSX.Element => {
         <style>{css}</style>
         <style>{css}</style>
       </Head>
       </Head>
       <div className={`slides ${MARP_CONTAINER_CLASS_NAME}`}>
       <div className={`slides ${MARP_CONTAINER_CLASS_NAME}`}>
-        <ReactMarkdown {...rendererOptions}>
+        <ReactMarkdown {...slideRendererOptions}>
           {children ?? '## No Contents'}
           {children ?? '## No Contents'}
         </ReactMarkdown>
         </ReactMarkdown>
       </div>
       </div>

+ 1 - 1
packages/presentation/src/client/consts/index.ts

@@ -4,7 +4,7 @@ import type { Options as RevealOptions } from 'reveal.js';
 export const MARP_CONTAINER_CLASS_NAME = 'marpit';
 export const MARP_CONTAINER_CLASS_NAME = 'marpit';
 
 
 export type PresentationOptions = {
 export type PresentationOptions = {
-  rendererOptions: ReactMarkdownOptions;
+  rendererOptions?: ReactMarkdownOptions;
   revealOptions?: RevealOptions;
   revealOptions?: RevealOptions;
   isDarkMode?: boolean;
   isDarkMode?: boolean;
   disableSeparationByHeader?: boolean;
   disableSeparationByHeader?: boolean;

+ 10 - 0
packages/presentation/vitest.config.ts

@@ -0,0 +1,10 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [react()],
+  test: {
+    environment: 'happy-dom',
+    globals: true,
+  },
+});

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

@@ -51,7 +51,7 @@
     "axios": "^1.15.0",
     "axios": "^1.15.0",
     "express": "^4.20.0",
     "express": "^4.20.0",
     "hast-util-select": "^6.0.2",
     "hast-util-select": "^6.0.2",
-    "mongoose": "^6.13.6",
+    "mongoose": "^6.13.9",
     "swr": "^2.3.2",
     "swr": "^2.3.2",
     "xss": "^1.0.15"
     "xss": "^1.0.15"
   },
   },

+ 20 - 0
packages/remark-drawio/src/components/DrawioViewer.module.scss

@@ -7,3 +7,23 @@
 .drawio-viewer * {
 .drawio-viewer * {
   box-sizing: content-box;
   box-sizing: content-box;
 }
 }
+
+// Revert host-page CSS that leaks into HTML rendered inside <foreignObject>.
+// drawio sizes each cell using UA-default HTML metrics and clips overflow via
+// an inline max-height wrapper, so non-default styles (e.g. line-height,
+// margin from a wrapping `.wiki` ruleset) cause label content to be cut off.
+//
+// `!important` is required: host selectors such as `.wiki ol:not(.nav) li`
+// outrank our scoped selector on specificity (`:not(.nav)` adds a class-level
+// weight). Defending the foreignObject content is an adversarial cross-cutting
+// concern, so we explicitly opt out of the specificity contest rather than
+// chasing host selectors.
+//
+// See: https://github.com/growilabs/growi/issues/11052
+.drawio-viewer foreignObject {
+  h1, h2, h3, h4, h5, h6,
+  p, ul, ol, li, blockquote,
+  img, video, table {
+    all: revert !important;
+  }
+}

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

@@ -38,7 +38,7 @@
     "express": "^4.20.0",
     "express": "^4.20.0",
     "express-validator": "^6.14.0",
     "express-validator": "^6.14.0",
     "http-errors": "^2.0.0",
     "http-errors": "^2.0.0",
-    "mongoose": "^6.13.6",
+    "mongoose": "^6.13.9",
     "swr": "^2.3.2",
     "swr": "^2.3.2",
     "xss": "^1.0.15"
     "xss": "^1.0.15"
   },
   },

+ 49 - 2
packages/ui/src/components/UserPicture.tsx

@@ -1,4 +1,5 @@
 import {
 import {
+  type CSSProperties,
   forwardRef,
   forwardRef,
   type JSX,
   type JSX,
   memo,
   memo,
@@ -33,7 +34,12 @@ type BaseUserPictureRootProps = {
   className?: string;
   className?: string;
 };
 };
 
 
-type UserPictureRootWithoutLinkProps = BaseUserPictureRootProps;
+type UserPictureRootWithoutLinkProps = BaseUserPictureRootProps & {
+  onClick?: () => void;
+  rootClassName?: string;
+  rootStyle?: CSSProperties;
+  testId?: string;
+};
 
 
 type UserPictureRootWithLinkProps = BaseUserPictureRootProps & {
 type UserPictureRootWithLinkProps = BaseUserPictureRootProps & {
   username: string;
   username: string;
@@ -43,8 +49,37 @@ const UserPictureRootWithoutLink = forwardRef<
   HTMLSpanElement,
   HTMLSpanElement,
   UserPictureRootWithoutLinkProps
   UserPictureRootWithoutLinkProps
 >((props, ref) => {
 >((props, ref) => {
+  const { onClick, rootClassName, rootStyle, testId } = props;
+  const interactive = onClick != null;
+  const resolvedStyle: CSSProperties | undefined = interactive
+    ? { cursor: 'pointer', ...rootStyle }
+    : rootStyle;
+  if (interactive) {
+    return (
+      // biome-ignore lint/a11y/useSemanticElements: UserPicture is used in varied layout contexts where a native button would break styling
+      <span
+        ref={ref}
+        className={rootClassName ?? props.className}
+        style={resolvedStyle}
+        data-testid={testId}
+        onClick={onClick}
+        onKeyDown={(e) => {
+          if (e.key === 'Enter' || e.key === ' ') onClick();
+        }}
+        role="button"
+        tabIndex={0}
+      >
+        {props.children}
+      </span>
+    );
+  }
   return (
   return (
-    <span ref={ref} className={props.className}>
+    <span
+      ref={ref}
+      className={rootClassName ?? props.className}
+      style={resolvedStyle}
+      data-testid={testId}
+    >
       {props.children}
       {props.children}
     </span>
     </span>
   );
   );
@@ -115,6 +150,10 @@ type Props = {
   noLink?: boolean;
   noLink?: boolean;
   noTooltip?: boolean;
   noTooltip?: boolean;
   className?: string;
   className?: string;
+  onClick?: () => void;
+  rootClassName?: string;
+  rootStyle?: CSSProperties;
+  testId?: string;
 };
 };
 
 
 export const UserPicture = memo((userProps: Props): JSX.Element => {
 export const UserPicture = memo((userProps: Props): JSX.Element => {
@@ -124,6 +163,10 @@ export const UserPicture = memo((userProps: Props): JSX.Element => {
     noLink,
     noLink,
     noTooltip,
     noTooltip,
     className: additionalClassName,
     className: additionalClassName,
+    onClick,
+    rootClassName,
+    rootStyle,
+    testId,
   } = userProps;
   } = userProps;
 
 
   // Extract user information
   // Extract user information
@@ -183,6 +226,10 @@ export const UserPicture = memo((userProps: Props): JSX.Element => {
         ref={rootRef}
         ref={rootRef}
         displayName={displayName}
         displayName={displayName}
         size={size}
         size={size}
+        onClick={onClick}
+        rootClassName={rootClassName}
+        rootStyle={rootStyle}
+        testId={testId}
       >
       >
         {children}
         {children}
       </UserPictureRootWithoutLink>
       </UserPictureRootWithoutLink>

File diff suppressed because it is too large
+ 303 - 164
pnpm-lock.yaml


+ 46 - 0
pnpm-workspace.yaml

@@ -1,3 +1,49 @@
 packages:
 packages:
   - 'apps/*'
   - 'apps/*'
   - 'packages/*'
   - 'packages/*'
+
+overrides:
+  # flat v6 is provided only by ESM, but @lykmapipo/common requires CommonJS version
+  '@lykmapipo/common>flat': 5.0.2
+  # mime v4 is provided only by ESM, but @lykmapipo/common requires CommonJS version
+  '@lykmapipo/common>mime': 3.0.0
+  # parse-json v6 is provided only by ESM, but @lykmapipo/common requires CommonJS version
+  '@lykmapipo/common>parse-json': 5.2.0
+  # CVE-2025-XXXXX: CRLF Injection + Prototype Pollution combo leads to HTTP Request Smuggling (CVSS 10.0).
+  # All versions < 1.15.0 are vulnerable.
+  axios: ^1.15.0
+
+packageExtensions:
+  # @orval/core bundles @stoplight/json-ref-resolver which requires lodash/get at runtime,
+  # but @orval/core does not declare lodash as a dependency.
+  '@orval/core':
+    dependencies:
+      lodash: '*'
+
+patchedDependencies:
+  # The patch excludes mathjax-full from the dependency graph of Marp Core.
+  '@marp-team/marp-core': packages/presentation/patches/@marp-team__marp-core.patch
+
+# pnpm v11+ unified allowlist: true=run install scripts, false=skip them.
+# Migrated from onlyBuiltDependencies (true) and ignoredBuiltDependencies (false).
+allowBuilds:
+  lefthook: true
+  '@swc/core': false
+  core-js: false
+  esbuild: false
+  leveldown: false
+  protobufjs: false
+  puppeteer: false
+  ttf2woff2: false
+  # Prisma: apps/app's `postinstall: prisma generate` covers the work that these
+  # packages' install scripts would do. In particular, `prisma generate` itself
+  # downloads the engine binary on demand (verified by removing
+  # libquery_engine-*.so.node and re-running `prisma generate` — the binary is
+  # restored byte-for-byte), so `@prisma/engines`' postinstall is redundant here.
+  '@prisma/client': false
+  '@prisma/engines': false
+  prisma: false
+  # sharp ships platform-specific prebuilt binaries via optional dependencies
+  # (e.g. @img/sharp-linux-x64, @img/sharp-libvips-linux-x64), so its install
+  # script (which would build libvips from source as a fallback) is not needed.
+  sharp: false

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