Explorar el Código

Merge branch 'master' into feat/160341-editor-guide

Yuki Takei hace 4 semanas
padre
commit
7a9dc52faf
Se han modificado 73 ficheros con 1634 adiciones y 900 borrados
  1. 53 12
      .claude/commands/invest-issue.md
  2. 46 0
      .claude/rules/github-cli.md
  3. 66 0
      .claude/rules/lsp.md
  4. 87 0
      .claude/rules/project-structure.md
  5. 1 5
      .claude/settings.json
  6. 0 252
      .claude/skills/monorepo-overview/SKILL.md
  7. 0 269
      .claude/skills/tech-stack/SKILL.md
  8. 3 1
      .devcontainer/app/devcontainer.json
  9. 5 0
      .devcontainer/app/postCreateCommand.sh
  10. 5 0
      .devcontainer/pdf-converter/postCreateCommand.sh
  11. 8 1
      .github/workflows/reusable-app-prod.yml
  12. 0 23
      .vscode/mcp.json
  13. 4 0
      .vscode/settings.json
  14. 23 28
      AGENTS.md
  15. 20 1
      CHANGELOG.md
  16. 23 0
      apps/app/.claude/rules/ui-pitfalls.md
  17. 3 0
      apps/app/.gitignore
  18. 19 0
      apps/app/bin/postbuild-server.ts
  19. 1 1
      apps/app/docker/README.md
  20. 17 5
      apps/app/package.json
  21. 16 0
      apps/app/prisma.config.ts
  22. 37 0
      apps/app/prisma/migrate.ts
  23. 0 0
      apps/app/prisma/migrations/.keep
  24. 516 0
      apps/app/prisma/schema.prisma
  25. 6 0
      apps/app/prisma/types.ts
  26. 4 6
      apps/app/src/client/components/Admin/AdminHome/AdminHome.tsx
  27. 2 2
      apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx
  28. 3 4
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  29. 5 8
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  30. 4 4
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx
  31. 2 2
      apps/app/src/client/components/PageRenameModal/PageRenameModal.tsx
  32. 1 0
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  33. 39 14
      apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx
  34. 3 1
      apps/app/src/client/util/watch-rendering-and-rescroll.ts
  35. 1 1
      apps/app/src/components/utils/use-lazy-loader.ts
  36. 4 1
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  37. 8 7
      apps/app/src/features/openai/server/services/client-delegator/get-client.ts
  38. 47 19
      apps/app/src/features/page-tree/components/SimpleItemContent.tsx
  39. 3 1
      apps/app/src/features/page-tree/components/TreeItemLayout.tsx
  40. 1 0
      apps/app/src/features/page/index.ts
  41. 1 0
      apps/app/src/features/page/models/index.ts
  42. 5 0
      apps/app/src/features/page/models/revision.ts
  43. 23 3
      apps/app/src/features/search/client/components/SearchModal.tsx
  44. 1 4
      apps/app/src/pages/admin/_shared/layout.tsx
  45. 1 3
      apps/app/src/server/routes/apiv3/page-listing.ts
  46. 13 2
      apps/app/src/server/routes/apiv3/page/index.ts
  47. 8 2
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  48. 8 2
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  49. 3 9
      apps/app/src/server/routes/apiv3/share-links.js
  50. 1 1
      apps/app/src/server/routes/apiv3/slack-integration.js
  51. 1 1
      apps/app/src/server/service/config-manager/config-definition.ts
  52. 16 13
      apps/app/src/server/service/file-uploader/multipart-uploader.spec.ts
  53. 10 9
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts
  54. 11 10
      apps/app/src/server/service/slack-integration.ts
  55. 9 10
      apps/app/src/server/util/is-simple-request.spec.ts
  56. 1 0
      apps/app/src/server/util/safe-path-utils.ts
  57. 2 3
      apps/app/src/services/renderer/recommended-whitelist.ts
  58. 3 3
      apps/app/src/states/ui/sidebar/sidebar.ts
  59. 11 4
      apps/app/src/stores/page.tsx
  60. 6 1
      apps/app/src/stores/user.tsx
  61. 5 0
      apps/app/src/utils/prisma.ts
  62. 1 1
      apps/app/tsconfig.json
  63. 1 1
      apps/slackbot-proxy/package.json
  64. 7 14
      biome.json
  65. 2 2
      package.json
  66. 1 8
      packages/core/src/models/serializers/user-serializer.ts
  67. 9 9
      packages/core/src/utils/objectid-utils.spec.ts
  68. 6 7
      packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts
  69. 29 41
      packages/core/src/utils/page-path-utils/index.spec.ts
  70. 1 0
      packages/remark-growi-directive/src/index.js
  71. 1 1
      packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/factory-attributes.js
  72. 1 1
      packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/factory-label.js
  73. 350 67
      pnpm-lock.yaml

+ 53 - 12
.claude/commands/invest-issue.md

@@ -33,14 +33,22 @@ Extract and display:
 
 
 ## Step 2: Update Labels — Mark as Under Investigation
 ## Step 2: Update Labels — Mark as Under Investigation
 
 
-Remove `phase/new` (if present) and add `phase/under-investigation`:
+Before applying any labels, fetch the exact label names from the repository:
 
 
 ```bash
 ```bash
-# Remove phase/new
-gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --remove-label "phase/new"
+gh label list --repo growilabs/growi --json name --limit 100
+```
+
+Use these exact names when calling `--remove-label` or `--add-label`. Label names in this repo include emoji prefixes (e.g. `"0️⃣ phase/new"`, `"1️⃣ phase/under-investigation"`), so always look them up rather than guessing.
+
+Remove the `phase/new` label (if present) and add `phase/under-investigation`, using the exact names returned above:
+
+```bash
+# Remove phase/new (use exact name from label list, e.g. "0️⃣ phase/new")
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --remove-label "{EXACT_PHASE_NEW_LABEL}"
 
 
-# Add phase/under-investigation
-gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "phase/under-investigation"
+# Add phase/under-investigation (use exact name from label list, e.g. "1️⃣ phase/under-investigation")
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "{EXACT_PHASE_UNDER_INVESTIGATION_LABEL}"
 ```
 ```
 
 
 If `phase/new` is not present, skip the removal step and only add `phase/under-investigation`.
 If `phase/new` is not present, skip the removal step and only add `phase/under-investigation`.
@@ -93,8 +101,10 @@ If code analysis alone is insufficient to confirm the root cause, attempt reprod
 If the problem is **confirmed** (root cause found in code OR reproduction succeeded):
 If the problem is **confirmed** (root cause found in code OR reproduction succeeded):
 
 
 ```bash
 ```bash
-gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --remove-label "phase/under-investigation"
-gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "phase/confirmed"
+# Use exact label names from the label list fetched in Step 2
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --remove-label "{EXACT_PHASE_UNDER_INVESTIGATION_LABEL}"
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "{EXACT_PHASE_CONFIRMED_LABEL}"
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "type/bug"
 ```
 ```
 
 
 ## Step 4: Report Findings
 ## Step 4: Report Findings
@@ -124,8 +134,9 @@ List specific files and changes needed, but do NOT apply them yet.}
 
 
 ### 4-B: Post Comment on Issue
 ### 4-B: Post Comment on Issue
 
 
-Detect the language of the issue body (from Step 1) and write the comment **in the same language**.
-For example, if the issue is written in Japanese, write the comment in Japanese.
+**CRITICAL — Language rule**: Detect the language of the issue body (from Step 1) and write the comment **strictly in that language**, regardless of the language used in this conversation.
+The issue body language takes absolute priority over the conversation language.
+For example, if the issue body is written in English, the comment MUST be in English even if the user conversed in Japanese — and vice versa.
 
 
 Post the findings as a GitHub issue comment:
 Post the findings as a GitHub issue comment:
 
 
@@ -168,10 +179,14 @@ After reporting, ask the user:
 
 
 Proceed only after explicit user approval.
 Proceed only after explicit user approval.
 
 
-### 5-A: Add WIP Label
+### 5-A: Add WIP Label — BEFORE Any Code Changes
+
+**MANDATORY — Do this FIRST, before creating a branch or touching any files.**
+
+Use the exact label name from the label list fetched in Step 2 (e.g. `"4️⃣ phase/WIP"`):
 
 
 ```bash
 ```bash
-gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "phase/WIP"
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "{EXACT_PHASE_WIP_LABEL}"
 ```
 ```
 
 
 ### 5-B: Create a Fix Branch
 ### 5-B: Create a Fix Branch
@@ -202,7 +217,21 @@ Example: `fix/12345-page-title-overflow`
   Fixes #ISSUE_NUMBER
   Fixes #ISSUE_NUMBER
   ```
   ```
 
 
-### 5-D: Open a Pull Request
+### 5-D: STOP — Ask for PR Approval
+
+**STOP HERE. Do not create a PR until the user explicitly approves.**
+
+Report the implementation summary and ask:
+
+> Implementation complete. Changes committed to `fix/{ISSUE_NUMBER}-{short-description}`.
+> Would you like me to:
+> 1. **Create a PR** — I'll open a pull request now
+> 2. **Review first** — you'll review the changes before PR
+> 3. **Stop here** — you'll handle the PR manually
+
+**Wait for the user's response before proceeding.**
+
+### 5-E: Open a Pull Request (Only if Approved)
 
 
 ```bash
 ```bash
 gh pr create \
 gh pr create \
@@ -231,6 +260,18 @@ EOF
 )"
 )"
 ```
 ```
 
 
+### 5-F: Update Labels — Mark as Resolved
+
+After the PR is created, update the labels:
+
+```bash
+# Remove WIP label
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --remove-label "{EXACT_PHASE_WIP_LABEL}"
+
+# Add resolved label
+gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "{EXACT_PHASE_RESOLVED_LABEL}"
+```
+
 ## Error Handling
 ## Error Handling
 
 
 - If the issue number is invalid or not found: display error from `gh` and stop
 - If the issue number is invalid or not found: display error from `gh` and stop

+ 46 - 0
.claude/rules/github-cli.md

@@ -0,0 +1,46 @@
+# GitHub CLI (gh) Requirements
+
+## CRITICAL: gh CLI Authentication is Mandatory
+
+When any task requires GitHub operations (PRs, issues, releases, checks, etc.), you MUST use the `gh` CLI.
+
+**If `gh` CLI is not authenticated or not available, you MUST:**
+
+1. **STOP immediately** — do NOT attempt any fallback (WebFetch, curl, API calls, etc.)
+2. **Tell the user** that `gh` CLI authentication is required
+3. **Prompt the user** to run `gh auth login` before continuing
+4. **Wait** — do not proceed until the user confirms authentication
+
+## Prohibited Fallbacks
+
+The following fallbacks are **STRICTLY FORBIDDEN** when `gh` is unavailable or unauthenticated:
+
+- Using `WebFetch` to scrape GitHub URLs
+- Using `curl` against the GitHub API directly
+- Using `WebSearch` to find PR/issue information
+- Any other workaround that bypasses `gh` CLI
+
+## Required Check
+
+Before any `gh` command, if you are unsure about authentication status, run:
+
+```bash
+gh auth status
+```
+
+If the output indicates the user is not logged in, **STOP and prompt**:
+
+> `gh` CLI is not authenticated. Please run `gh auth login` and try again.
+
+## Example Correct Behavior
+
+```
+# gh not authenticated → STOP
+User: Please review PR #123
+Assistant: gh CLI is not authenticated. Please run `gh auth login` first, then retry.
+[Session stops — no fallback attempted]
+```
+
+## Why This Rule Exists
+
+Falling back to WebFetch or other HTTP-based access when `gh` is unavailable silently bypasses authentication and can expose unintended behavior. Stopping and prompting ensures credentials are properly configured before any GitHub interaction.

+ 66 - 0
.claude/rules/lsp.md

@@ -0,0 +1,66 @@
+# LSP Usage
+
+The `LSP` tool provides TypeScript-aware code intelligence. Prefer it over `grep`/`find` for symbol-level queries.
+
+## Tool availability (read before concluding LSP is unavailable)
+
+The way `LSP` is exposed differs between the main session and sub-agents. Check which context you are in before concluding it is unavailable.
+
+**Main session (this file is in your system prompt):**
+`LSP` is registered as a **deferred tool** — its schema is not loaded at session start, so it will NOT appear in the initial top-level tool list. It instead shows up by name inside the session-start `<system-reminder>` listing deferred tools.
+
+Do not conclude LSP is unavailable just because it isn't in the initial tool list. To use it:
+
+1. Confirm `LSP` appears in the deferred-tool list in the session-start system-reminder.
+2. Load its schema with `ToolSearch` using `query: "select:LSP"`.
+3. After that, call `LSP` like any other tool.
+
+Only if `LSP` is missing from the deferred-tool list AND `ToolSearch` with `select:LSP` returns no match should you treat LSP as disabled and fall back to `grep`.
+
+**Sub-agents (Explore, general-purpose, etc.):**
+`LSP` is provided directly in the initial tool list — no `ToolSearch` step needed. `ToolSearch` itself is not available in sub-agents. Just call `LSP` as a normal tool.
+
+Note: `.claude/rules/` files are NOT injected into sub-agent system prompts. A sub-agent will not know the guidance in this file unless the parent includes it in the `Agent` prompt. When delegating symbol-level research (definition lookup, caller search, type inspection) to a sub-agent, restate the key rules inline — at minimum: "prefer LSP over grep for TypeScript symbol queries; use `incomingCalls` for callers, `goToDefinition` for definitions".
+
+## Auto-start behavior
+
+The `typescript-language-server` starts automatically when the LSP tool is first invoked — no manual startup or health check is needed. If the server isn't installed, the tool returns an error; in that case fall back to `grep`.
+
+In the devcontainer, `typescript-language-server` is pre-installed globally via `postCreateCommand.sh`. It auto-detects and uses the workspace's `node_modules/typescript` at runtime.
+
+## When to use LSP (not grep)
+
+| Task | Preferred LSP operation |
+|------|------------------------|
+| Find where a function/class/type is defined | `goToDefinition` |
+| Find all call sites **including imports** | `findReferences` (see caveat below) |
+| Find which functions call a given function | `incomingCalls` ← prefer this over `findReferences` for callers |
+| Check a variable's type or JSDoc | `hover` |
+| List all exports in a file | `documentSymbol` |
+| Find what implements an interface | `goToImplementation` |
+| Trace what a function calls | `outgoingCalls` |
+
+## Decision rule
+
+- **Use LSP** when the query is about a *symbol* (function, class, type, variable) — LSP understands TypeScript semantics and won't false-match string occurrences or comments.
+- **Use grep** when searching for string literals, comments, config values, or when LSP returns no results (e.g., generated code, `.js` files without types).
+
+## `findReferences` — lazy-loading caveat
+
+TypeScript LSP loads files **on demand**. On a cold server (first query after devcontainer start), calling `findReferences` from the *definition file* may return only the definition itself because consumer files haven't been loaded yet.
+
+**Mitigation strategies (in order of preference):**
+
+1. **Prefer `incomingCalls`** over `findReferences` when you want callers. It correctly resolves cross-file call sites even on a cold server.
+2. If you need `findReferences` with full results, call it from a **known call site** (not the definition). After any file in the consumer chain is queried, the server loads it and subsequent `findReferences` calls return complete results.
+3. As a last resort, run a `hover` on an import statement in the consumer file first to warm up that file, then retry `findReferences` from the definition.
+
+## Required: line + character
+
+LSP operations require `line` and `character` (both 1-based). Read the file first to identify the exact position of the symbol, then call LSP.
+
+```
+# Example: symbol starts at col 14 on line 85
+export const useCollaborativeEditorMode = (
+             ^--- character 14
+```

+ 87 - 0
.claude/rules/project-structure.md

@@ -0,0 +1,87 @@
+# Project Structure
+
+## Monorepo Layout
+
+```
+growi/
+├── apps/
+│   ├── app/           # Main GROWI application (Next.js + Express + MongoDB)
+│   ├── pdf-converter/ # PDF conversion microservice (Ts.ED + Puppeteer)
+│   └── slackbot-proxy/# Slack integration proxy (Ts.ED + TypeORM + MySQL)
+├── packages/          # Shared libraries
+│   ├── core/          # Domain types & utilities hub (see below)
+│   ├── ui/            # React component library
+│   ├── editor/        # Markdown editor
+│   └── pluginkit/     # Plugin framework
+└── .claude/
+    ├── rules/         # Always loaded into every session
+    ├── skills/        # Load on demand via Skill tool
+    └── agents/        # Specialized subagents
+```
+
+## @growi/core — Shared Domain Hub
+
+`@growi/core` is the single source of truth for cross-package types and utilities, depended on by all other packages (10+ consumers).
+
+- **Shared interfaces go here** — `IPage`, `IUser`, `IRevision`, `Ref<T>`, `HasObjectId`, etc.
+- **Cross-cutting pure utilities** — page path validation, ObjectId checks, `serializeUserSecurely()`
+- **Global type augmentations** — `declare global` in `index.ts` propagates to all consumers
+- Minimal runtime deps (only `bson-objectid`); safe to import from both server and client
+
+> When adding a new interface used by multiple packages, put it in `@growi/core`, not in the consuming package.
+
+## Build Order Management
+
+Turborepo build dependencies are declared **explicitly**, not auto-detected from `pnpm-workspace.yaml`.
+
+When a package gains a new workspace dependency on another buildable package (one that produces `dist/`), declare it in a per-package `turbo.json`:
+
+```json
+// packages/my-package/turbo.json
+{
+  "extends": ["//"],
+  "tasks": {
+    "build": { "dependsOn": ["@growi/some-dep#build"] },
+    "dev":   { "dependsOn": ["@growi/some-dep#dev"] }
+  }
+}
+```
+
+- `"extends": ["//"]` inherits root task definitions; only add the extra `dependsOn`
+- Omitting this causes Turborepo to build in the wrong order → missing `dist/` → type errors
+
+## Adding Workspace Dependencies
+
+When referencing another package in the monorepo, use the `workspace:` protocol — never a hardcoded version:
+
+```json
+{ "@growi/core": "workspace:^" }
+```
+
+After editing `package.json`, run `pnpm install` from the repo root to update the lockfile.
+
+## New Package Defaults
+
+When creating a new package, use **Biome + Vitest** from the start (not ESLint/Prettier/Jest):
+
+```bash
+biome check <files>          # lint + format check
+biome check --write <files>  # auto-fix
+```
+
+Configuration lives in the root `biome.json` (inherited by all packages). Legacy packages may still use ESLint during migration — don't add ESLint to new packages.
+
+## Changeset Workflow
+
+```bash
+# 1. After making changes that affect published packages:
+npx changeset          # Describe the change, select bump type
+
+# 2. Commit both code and the generated .changeset/*.md file
+
+# 3. On release:
+pnpm run version-subpackages   # Updates CHANGELOG.md + package.json versions
+pnpm run release-subpackages   # Publishes @growi/core, @growi/pluginkit to npm
+```
+
+Published packages: `@growi/core`, `@growi/pluginkit`. Internal packages do not need changesets.

+ 1 - 5
.claude/settings.json

@@ -23,9 +23,7 @@
       "Bash(gh pr diff *)",
       "Bash(gh pr diff *)",
       "Bash(ls *)",
       "Bash(ls *)",
       "WebFetch(domain:github.com)",
       "WebFetch(domain:github.com)",
-      "mcp__context7__*",
       "mcp__plugin_context7_*",
       "mcp__plugin_context7_*",
-      "mcp__github__*",
       "WebSearch",
       "WebSearch",
       "WebFetch"
       "WebFetch"
     ]
     ]
@@ -57,8 +55,6 @@
   },
   },
   "enabledPlugins": {
   "enabledPlugins": {
     "context7@claude-plugins-official": true,
     "context7@claude-plugins-official": true,
-    "github@claude-plugins-official": true,
-    "typescript-lsp@claude-plugins-official": true,
-    "playwright@claude-plugins-official": true
+    "typescript-lsp@claude-plugins-official": true
   }
   }
 }
 }

+ 0 - 252
.claude/skills/monorepo-overview/SKILL.md

@@ -1,252 +0,0 @@
----
-name: monorepo-overview
-description: GROWI monorepo structure, workspace organization, and architectural principles. Auto-invoked for all GROWI development work.
-user-invocable: false
----
-
-# GROWI Monorepo Overview
-
-GROWI is a team collaboration wiki platform built as a monorepo using **pnpm workspace + Turborepo**.
-
-## Monorepo Structure
-
-```
-growi/
-├── apps/                    # Applications
-│   ├── app/                # Main GROWI application (Next.js + Express + MongoDB)
-│   ├── pdf-converter/      # PDF conversion microservice (Ts.ED + Puppeteer)
-│   └── slackbot-proxy/     # Slack integration proxy (Ts.ED + TypeORM + MySQL)
-├── packages/               # Shared libraries
-│   ├── core/              # Core utilities and shared logic
-│   ├── core-styles/       # Common styles (SCSS)
-│   ├── editor/            # Markdown editor components
-│   ├── ui/                # UI component library
-│   ├── pluginkit/         # Plugin framework
-│   ├── slack/             # Slack integration utilities
-│   ├── presentation/      # Presentation mode
-│   ├── pdf-converter-client/ # PDF converter client library
-│   └── remark-*/          # Markdown plugins (remark-lsx, remark-drawio, etc.)
-└── Configuration files
-    ├── pnpm-workspace.yaml
-    ├── turbo.json
-    ├── package.json
-    └── .changeset/
-```
-
-## Workspace Management
-
-### pnpm Workspace
-
-All packages are managed via **pnpm workspace**. Package references use the `workspace:` protocol:
-
-```json
-{
-  "dependencies": {
-    "@growi/core": "workspace:^",
-    "@growi/ui": "workspace:^"
-  }
-}
-```
-
-### Turborepo Orchestration
-
-Turborepo handles task orchestration with caching and parallelization:
-
-```bash
-# Run tasks across all workspaces
-turbo run dev
-turbo run test
-turbo run lint
-turbo run build
-
-# Filter to specific package
-turbo run test --filter @growi/app
-turbo run lint --filter @growi/core
-```
-
-### Build Order Management
-
-Build dependencies in this monorepo are **not** declared with `dependsOn: ["^build"]` (the automatic workspace-dependency mode). Instead, they are declared **explicitly** — either in the root `turbo.json` for legacy entries, or in per-package `turbo.json` files for newer packages.
-
-**When to update**: whenever a package gains a new workspace dependency on another buildable package (one that produces a `dist/`), declare the build-order dependency explicitly. Without it, Turborepo may build in the wrong order, causing missing `dist/` files or type errors.
-
-**Pattern — per-package `turbo.json`** (preferred for new dependencies):
-
-```json
-// packages/my-package/turbo.json
-{
-  "extends": ["//"],
-  "tasks": {
-    "build": { "dependsOn": ["@growi/some-dep#build"] },
-    "dev":   { "dependsOn": ["@growi/some-dep#dev"] }
-  }
-}
-```
-
-- `"extends": ["//"]` inherits all root task definitions; only add the extra `dependsOn`
-- Keep root `turbo.json` clean — package-level overrides live with the package that owns the dependency
-- For packages with multiple tasks (watch, lint, test), mirror the dependency in each relevant task
-
-**Existing examples**:
-- `packages/slack/turbo.json` — `build`/`dev` depend on `@growi/logger`
-- `packages/remark-attachment-refs/turbo.json` — all tasks depend on `@growi/core`, `@growi/logger`, `@growi/remark-growi-directive`, `@growi/ui`
-- Root `turbo.json` — `@growi/ui#build` depends on `@growi/core#build` (pre-dates the per-package pattern)
-
-## Architectural Principles
-
-### 1. Feature-Based Architecture (Recommended)
-
-**All packages should prefer feature-based organization**:
-
-```
-{package}/src/
-├── features/              # Feature modules
-│   ├── {feature-name}/
-│   │   ├── index.ts      # Main export
-│   │   ├── interfaces/   # TypeScript types
-│   │   ├── server/       # Server-side logic (if applicable)
-│   │   ├── client/       # Client-side logic (if applicable)
-│   │   └── utils/        # Shared utilities
-```
-
-**Benefits**:
-- Clear boundaries between features
-- Easy to locate related code
-- Facilitates gradual migration from legacy structure
-
-### 2. Server-Client Separation
-
-For full-stack packages (like apps/app), separate server and client logic:
-
-- **Server code**: Node.js runtime, database access, API routes
-- **Client code**: Browser runtime, React components, UI state
-
-This enables better code splitting and prevents server-only code from being bundled into client.
-
-### 3. Shared Libraries in packages/
-
-Common code should be extracted to `packages/`:
-
-- **core**: Domain hub (see below)
-- **ui**: Reusable React components
-- **editor**: Markdown editor
-- **pluginkit**: Plugin system framework
-
-#### @growi/core — Domain & Utilities Hub
-
-`@growi/core` is the foundational shared package depended on by all other packages (10 consumers). Its responsibilities:
-
-- **Domain type definitions** — Single source of truth for cross-package interfaces (`IPage`, `IUser`, `IRevision`, `Ref<T>`, `HasObjectId`, etc.)
-- **Cross-cutting utilities** — Pure functions for page path validation, ObjectId checks, serialization (e.g., `serializeUserSecurely()`)
-- **System constants** — File types, plugin configs, scope enums
-- **Global type augmentations** — Runtime/polyfill type declarations visible to all consumers (e.g., `RegExp.escape()` via `declare global` in `index.ts`)
-
-Key patterns:
-
-1. **Shared types and global augmentations go in `@growi/core`** — Not duplicated per-package. `declare global` in `index.ts` propagates to all consumers through the module graph.
-2. **Subpath exports for granular imports** — `@growi/core/dist/utils/page-path-utils` instead of barrel imports from root.
-3. **Minimal runtime dependencies** — Only `bson-objectid`; ~70% types. Safe to import from both server and client contexts.
-4. **Server-specific interfaces are namespaced** — Under `interfaces/server/`.
-5. **Dual format (ESM + CJS)** — Built via Vite with `preserveModules: true` and `vite-plugin-dts` (`copyDtsFiles: true`).
-
-## Version Management with Changeset
-
-GROWI uses **Changesets** for version management and release notes:
-
-```bash
-# Add a changeset (after making changes)
-npx changeset
-
-# Version bump (generates CHANGELOGs and updates versions)
-pnpm run version-subpackages
-
-# Publish packages to npm (for @growi/core, @growi/pluginkit)
-pnpm run release-subpackages
-```
-
-### Changeset Workflow
-
-1. Make code changes
-2. Run `npx changeset` and describe the change
-3. Commit both code and `.changeset/*.md` file
-4. On release, run `pnpm run version-subpackages`
-5. Changesets automatically updates `CHANGELOG.md` and `package.json` versions
-
-### Version Schemes
-
-- **Main app** (`apps/app`): Manual versioning with RC prereleases
-  - `pnpm run version:patch`, `pnpm run version:prerelease`
-- **Shared libraries** (`packages/core`, `packages/pluginkit`): Changeset-managed
-- **Microservices** (`apps/pdf-converter`, `apps/slackbot-proxy`): Independent versioning
-
-## Package Categories
-
-### Applications (apps/)
-
-| Package | Description | Tech Stack |
-|---------|-------------|------------|
-| **@growi/app** | Main wiki application | Next.js (Pages Router), Express, MongoDB, Jotai, SWR |
-| **@growi/pdf-converter** | PDF export service | Ts.ED, Puppeteer |
-| **@growi/slackbot-proxy** | Slack bot proxy | Ts.ED, TypeORM, MySQL |
-
-### Core Libraries (packages/)
-
-| Package | Description | Published to npm |
-|---------|-------------|------------------|
-| **@growi/core** | Core utilities | ✅ |
-| **@growi/pluginkit** | Plugin framework | ✅ |
-| **@growi/ui** | UI components | ❌ (internal) |
-| **@growi/editor** | Markdown editor | ❌ (internal) |
-| **@growi/core-styles** | Common styles | ❌ (internal) |
-
-## Development Workflow
-
-### Initial Setup
-
-```bash
-# Install dependencies for all packages
-pnpm install
-
-# Bootstrap (install + build dependencies)
-turbo run bootstrap
-```
-
-### Daily Development
-
-```bash
-# Start all dev servers (apps/app + dependencies)
-turbo run dev
-
-# Run a specific test file (from package directory)
-pnpm vitest run yjs.integ
-
-# Run ALL tests / lint for a package
-turbo run test --filter @growi/app
-turbo run lint --filter @growi/core
-```
-
-### Cross-Package Development
-
-When modifying shared libraries (packages/*), ensure dependent apps reflect changes:
-
-1. Make changes to `packages/core`
-2. Turborepo automatically detects changes and rebuilds dependents
-3. Test in `apps/app` to verify
-
-## Key Configuration Files
-
-- **pnpm-workspace.yaml**: Defines workspace packages
-- **turbo.json**: Turborepo pipeline configuration
-- **.changeset/config.json**: Changeset configuration
-- **tsconfig.base.json**: Base TypeScript config for all packages
-- **vitest.workspace.mts**: Vitest workspace config
-- **biome.json**: Biome linter/formatter config
-
-## Design Principles Summary
-
-1. **Feature Isolation**: Use feature-based architecture for new code
-2. **Server-Client Separation**: Keep server and client code separate
-3. **Shared Libraries**: Extract common code to packages/
-4. **Type-Driven Development**: Define interfaces before implementation
-5. **Progressive Enhancement**: Migrate legacy code gradually
-6. **Version Control**: Use Changesets for release management

+ 0 - 269
.claude/skills/tech-stack/SKILL.md

@@ -1,269 +0,0 @@
----
-name: tech-stack
-description: GROWI technology stack, build tools, and global commands. Auto-invoked for all GROWI development work.
-user-invocable: false
----
-
-# GROWI Tech Stack
-
-## Core Technologies
-
-- **TypeScript** ~5.0.0
-- **Node.js** ^18 || ^20
-- **MongoDB** with **Mongoose** ^6.13.6 (apps/app)
-- **MySQL** with **TypeORM** 0.2.x (apps/slackbot-proxy)
-
-## Frontend Framework
-
-- **React** 18.x
-- **Next.js** (Pages Router) - Full-stack framework for apps/app
-
-## State Management & Data Fetching (Global Standard)
-
-- **Jotai** - Atomic state management (recommended for all packages with UI state)
-  - Use for UI state, form state, modal state, etc.
-  - Lightweight, TypeScript-first, minimal boilerplate
-
-- **SWR** ^2.3.2 - Data fetching with caching
-  - Use for API data fetching with automatic revalidation
-  - Works seamlessly with RESTful APIs
-
-### Why Jotai + SWR?
-
-- **Separation of concerns**: Jotai for UI state, SWR for server state
-- **Performance**: Fine-grained reactivity (Jotai) + intelligent caching (SWR)
-- **Type safety**: Both libraries have excellent TypeScript support
-- **Simplicity**: Minimal API surface, easy to learn
-
-## Build & Development Tools
-
-### Package Management
-- **pnpm** Package manager (faster, more efficient than npm/yarn)
-
-### Monorepo Orchestration
-- **Turborepo** ^2.1.3 - Build system with caching and parallelization
-
-### Linter & Formatter
-- **Biome** ^2.2.6 - Unified linter and formatter (recommended)
-  - Replaces ESLint + Prettier
-  - Significantly faster (10-100x)
-  - Configuration: `biome.json`
-
-```bash
-# Lint and format check
-biome check <files>
-
-# Auto-fix issues
-biome check --write <files>
-```
-
-- **Stylelint** ^16.5.0 - SCSS/CSS linter
-  - Configuration: `.stylelintrc.js`
-
-```bash
-# Lint styles
-stylelint "src/**/*.scss"
-```
-
-### Testing
-- **Vitest** ^2.1.1 - Unit and integration testing (recommended)
-  - Fast, Vite-powered
-  - Jest-compatible API
-  - Configuration: `vitest.workspace.mts`
-
-- **React Testing Library** ^16.0.1 - Component testing
-  - User-centric testing approach
-
-- **vitest-mock-extended** ^2.0.2 - Type-safe mocking
-  - TypeScript autocomplete for mocks
-
-- **Playwright** ^1.49.1 - E2E testing
-  - Cross-browser testing
-
-## Essential Commands (Global)
-
-### Development
-
-```bash
-# Start all dev servers (apps/app + dependencies)
-turbo run dev
-
-# Start dev server for specific package
-turbo run dev --filter @growi/app
-
-# Install dependencies for all packages
-pnpm install
-
-# Bootstrap (install + build dependencies)
-turbo run bootstrap
-```
-
-### Testing & Quality
-
-```bash
-# Run a specific test file (from package directory, e.g. apps/app)
-pnpm vitest run yjs.integ          # Partial file name match
-pnpm vitest run helper.spec        # Works for any test file
-pnpm vitest run yjs.integ --repeat=10  # Repeat for flaky test detection
-
-# Run ALL tests for a package (uses Turborepo caching)
-turbo run test --filter @growi/app
-
-# Run linters for specific package
-turbo run lint --filter @growi/app
-```
-
-### Building
-
-```bash
-# Build all packages
-turbo run build
-
-# Build specific package
-turbo run build --filter @growi/core
-```
-
-## Turborepo Task Filtering
-
-Turborepo uses `--filter` to target specific packages:
-
-```bash
-# Run task for single package
-turbo run test --filter @growi/app
-
-# Run task for multiple packages
-turbo run build --filter @growi/core --filter @growi/ui
-
-# Run task for package and its dependencies
-turbo run build --filter @growi/app...
-```
-
-## Important Configuration Files
-
-### Workspace Configuration
-- **pnpm-workspace.yaml** - Defines workspace packages
-  ```yaml
-  packages:
-    - 'apps/*'
-    - 'packages/*'
-  ```
-
-### Build Configuration
-- **turbo.json** - Turborepo pipeline configuration
-  - Defines task dependencies, caching, and outputs
-
-### TypeScript Configuration
-- **tsconfig.base.json** - Base TypeScript config extended by all packages
-  - **Target**: ESNext
-  - **Module**: ESNext
-  - **Strict Mode**: Enabled (`strict: true`)
-  - **Module Resolution**: Bundler
-  - **Allow JS**: true (for gradual migration)
-  - **Isolated Modules**: true (required for Vite, SWC)
-
-Package-specific tsconfig.json example:
-```json
-{
-  "extends": "../../tsconfig.base.json",
-  "compilerOptions": {
-    "outDir": "./dist",
-    "rootDir": "./src"
-  },
-  "include": ["src/**/*"],
-  "exclude": ["node_modules", "dist", "**/*.spec.ts"]
-}
-```
-
-### Testing Configuration
-- **vitest.workspace.mts** - Vitest workspace config
-  - Defines test environments (Node.js, happy-dom)
-  - Configures coverage
-
-### Linter Configuration
-- **biome.json** - Biome linter/formatter config
-  - Rules, ignore patterns, formatting options
-
-## Development Best Practices
-
-### Command Usage
-
-1. **Use Turborepo for full-package tasks** (all tests, lint, build):
-   - ✅ `turbo run test --filter @growi/app`
-   - ❌ `cd apps/app && pnpm test` (bypasses Turborepo caching)
-2. **Use vitest directly for individual test files** (from package directory):
-   - ✅ `pnpm vitest run yjs.integ` (simple, fast)
-   - ❌ `turbo run test --filter @growi/app -- yjs.integ` (unnecessary overhead)
-
-2. **Use pnpm for package management**:
-   - ✅ `pnpm install`
-   - ❌ `npm install` or `yarn install`
-
-3. **Run tasks from workspace root**:
-   - Turborepo handles cross-package dependencies
-   - Caching works best from root
-
-### State Management Guidelines
-
-1. **Use Jotai for UI state**:
-   ```typescript
-   // Example: Modal state
-   import { atom } from 'jotai';
-
-   export const isModalOpenAtom = atom(false);
-   ```
-
-2. **Use SWR for server state**:
-   ```typescript
-   // Example: Fetching pages
-   import useSWR from 'swr';
-
-   const { data, error, isLoading } = useSWR('/api/pages', fetcher);
-   ```
-
-3. **Avoid mixing concerns**:
-   - Don't store server data in Jotai atoms
-   - Don't manage UI state with SWR
-
-## Migration Notes
-
-- **New packages**: Use Biome + Vitest from the start
-- **Legacy packages**: Can continue using existing tools during migration
-- **Gradual migration**: Prefer updating to Biome + Vitest when modifying existing files
-
-## Technology Decisions
-
-### Why Next.js Pages Router (not App Router)?
-
-- GROWI started before App Router was stable
-- Pages Router is well-established and stable
-- Migration to App Router is being considered for future versions
-
-### Why Jotai (not Redux/Zustand)?
-
-- **Atomic approach**: More flexible than Redux, simpler than Recoil
-- **TypeScript-first**: Excellent type inference
-- **Performance**: Fine-grained reactivity, no unnecessary re-renders
-- **Minimal boilerplate**: Less code than Redux
-
-### Why SWR (not React Query)?
-
-- **Simplicity**: Smaller API surface
-- **Vercel integration**: Built by Vercel (same as Next.js)
-- **Performance**: Optimized for Next.js SSR/SSG
-
-### Why Biome (not ESLint + Prettier)?
-
-- **Speed**: 10-100x faster than ESLint
-- **Single tool**: Replaces both ESLint and Prettier
-- **Consistency**: No conflicts between linter and formatter
-- **Growing ecosystem**: Active development, Rust-based
-
-## Package-Specific Tech Stacks
-
-Different apps in the monorepo may use different tech stacks:
-
-- **apps/app**: Next.js + Express + MongoDB + Jotai + SWR
-- **apps/pdf-converter**: Ts.ED + Puppeteer
-- **apps/slackbot-proxy**: Ts.ED + TypeORM + MySQL
-
-See package-specific CLAUDE.md or skills for details.

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

@@ -49,7 +49,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"

+ 5 - 0
.devcontainer/app/postCreateCommand.sh

@@ -25,6 +25,11 @@ pnpm config set store-dir /workspace/.pnpm-store
 # Install turbo
 # Install turbo
 pnpm install turbo --global
 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
+
 # Install dependencies
 # Install dependencies
 turbo run bootstrap
 turbo run bootstrap
 
 

+ 5 - 0
.devcontainer/pdf-converter/postCreateCommand.sh

@@ -20,6 +20,11 @@ pnpm i -g pnpm
 # Install turbo
 # Install turbo
 pnpm install turbo --global
 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
+
 # Install dependencies
 # Install dependencies
 turbo run bootstrap
 turbo run bootstrap
 
 

+ 8 - 1
.github/workflows/reusable-app-prod.yml

@@ -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
@@ -124,6 +127,8 @@ 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@v4
@@ -193,6 +198,8 @@ 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

+ 0 - 23
.vscode/mcp.json

@@ -1,23 +0,0 @@
-{
-  "servers": {
-    "context7": {
-      "type": "http",
-      "url": "https://mcp.context7.com/mcp"
-    },
-    "serena": {
-      "type": "stdio",
-      "command": "uvx",
-      "args": [
-        "--from",
-        "git+https://github.com/oraios/serena",
-        "serena",
-        "start-mcp-server",
-        "--context",
-        "ide",
-        "--project",
-        ".",
-        "--enable-web-dashboard=false"
-      ]
-    }
-  }
-}

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

+ 23 - 28
AGENTS.md

@@ -14,40 +14,37 @@ GROWI is a team collaboration wiki platform using Markdown, featuring hierarchic
 
 
 ## Knowledge Base
 ## Knowledge Base
 
 
-### Claude Code Skills (Auto-Invoked)
+### Always-Loaded Context
 
 
-Technical information is available in **Claude Code Skills** (`.claude/skills/`), which are automatically invoked during development.
-
-**Global Skills** (always loaded):
-
-| Skill | Description |
-|-------|-------------|
-| **monorepo-overview** | Monorepo structure, workspace organization, Changeset versioning |
-| **tech-stack** | Technology stack, pnpm/Turborepo, TypeScript, Biome |
-
-**Rules** (always applied):
+**Rules** (`.claude/rules/`) — loaded into every session automatically:
 
 
 | Rule | Description |
 | Rule | Description |
 |------|-------------|
 |------|-------------|
+| **project-structure** | Monorepo layout, @growi/core role, build order, Changeset workflow |
 | **coding-style** | Coding conventions, naming, exports, immutability, comments |
 | **coding-style** | Coding conventions, naming, exports, immutability, comments |
 | **security** | Security checklist, secret management, OWASP vulnerability prevention |
 | **security** | Security checklist, secret management, OWASP vulnerability prevention |
 | **performance** | Model selection, context management, build troubleshooting |
 | **performance** | Model selection, context management, build troubleshooting |
+| **github-cli** | **CRITICAL**: gh CLI auth required; stop immediately if unauthenticated |
 
 
-**Agents** (specialized):
+| **testing** | Test commands, pnpm vitest usage |
+
+### On-Demand Skills
+
+**Agents** (specialized subagents):
 
 
 | Agent | Description |
 | Agent | Description |
 |-------|-------------|
 |-------|-------------|
 | **build-error-resolver** | TypeScript/build error resolution with minimal diffs |
 | **build-error-resolver** | TypeScript/build error resolution with minimal diffs |
 | **security-reviewer** | Security vulnerability detection, OWASP Top 10 |
 | **security-reviewer** | Security vulnerability detection, OWASP Top 10 |
 
 
-**Commands** (user-invocable):
+**Commands** (user-invocable via `/`):
 
 
 | Command | Description |
 | Command | Description |
 |---------|-------------|
 |---------|-------------|
 | **/tdd** | Test-driven development workflow |
 | **/tdd** | Test-driven development workflow |
 | **/learn** | Extract reusable patterns from sessions |
 | **/learn** | Extract reusable patterns from sessions |
 
 
-**apps/app Skills** (loaded when working in apps/app):
+**apps/app Skills** (load via Skill tool when working in apps/app):
 
 
 | Skill | Description |
 | Skill | Description |
 |-------|-------------|
 |-------|-------------|
@@ -63,10 +60,6 @@ Each application has its own CLAUDE.md with detailed instructions:
 - `apps/pdf-converter/CLAUDE.md` - PDF conversion microservice
 - `apps/pdf-converter/CLAUDE.md` - PDF conversion microservice
 - `apps/slackbot-proxy/CLAUDE.md` - Slack integration proxy
 - `apps/slackbot-proxy/CLAUDE.md` - Slack integration proxy
 
 
-### Serena Memories
-
-Additional detailed specifications are stored in **Serena memories** and can be referenced when needed for specific features or subsystems.
-
 ## Quick Reference
 ## Quick Reference
 
 
 ### Essential Commands (Global)
 ### Essential Commands (Global)
@@ -94,9 +87,9 @@ growi/
 │   └── slackbot-proxy/     # Slack integration proxy
 │   └── slackbot-proxy/     # Slack integration proxy
 ├── packages/               # Shared libraries (@growi/core, @growi/ui, etc.)
 ├── packages/               # Shared libraries (@growi/core, @growi/ui, etc.)
 └── .claude/
 └── .claude/
-    ├── skills/             # Claude Code skills (auto-loaded)
-    ├── rules/              # Coding standards (always applied)
-    ├── agents/             # Specialized agents
+    ├── rules/              # Always loaded into every session
+    ├── skills/             # Load on demand via Skill tool
+    ├── agents/             # Specialized subagents
     └── commands/           # User-invocable commands (/tdd, /learn)
     └── commands/           # User-invocable commands (/tdd, /learn)
 ```
 ```
 
 
@@ -104,11 +97,13 @@ growi/
 
 
 1. **Feature-Based Architecture**: Create new features in `features/{feature-name}/`
 1. **Feature-Based Architecture**: Create new features in `features/{feature-name}/`
 2. **Server-Client Separation**: Keep server and client code separate
 2. **Server-Client Separation**: Keep server and client code separate
-3. **State Management**: Jotai for UI state, SWR for data fetching
-4. **Named Exports**: Prefer named exports (except Next.js pages)
-5. **Test Co-location**: Place test files next to source files
-6. **Type Safety**: Use strict TypeScript throughout
-7. **Changeset**: Use `npx changeset` for version management
+3. **State Management**: Jotai for UI state, SWR for data fetching *(Jotai: atomic/TypeScript-first for UI; SWR: caching/revalidation for server state — don't mix)*
+4. **Linter/Formatter**: Biome (replaces ESLint + Prettier); Stylelint for SCSS *(10–100x faster, single config in `biome.json`)*
+5. **Frontend Framework**: Next.js Pages Router *(App Router not yet adopted — GROWI predates its stability)*
+6. **Named Exports**: Prefer named exports (except Next.js pages)
+7. **Test Co-location**: Place test files next to source files
+8. **Type Safety**: Use strict TypeScript throughout
+9. **Changeset**: Use `npx changeset` for version management
 
 
 ## Before Committing
 ## Before Committing
 
 
@@ -132,6 +127,6 @@ pnpm run build
 ---
 ---
 
 
 For detailed information, refer to:
 For detailed information, refer to:
-- **Rules**: `.claude/rules/` (coding standards)
-- **Skills**: `.claude/skills/` (technical knowledge)
+- **Rules**: `.claude/rules/` (always loaded — coding standards, project structure)
+- **Skills**: `.claude/skills/` (on-demand — app-specific patterns)
 - **Package docs**: `apps/*/CLAUDE.md` (package-specific)
 - **Package docs**: `apps/*/CLAUDE.md` (package-specific)

+ 20 - 1
CHANGELOG.md

@@ -1,9 +1,28 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/growilabs/compare/v7.5.1...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.5.2...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.5.2](https://github.com/growilabs/compare/v7.5.1...v7.5.2) - 2026-04-22
+
+### 🚀 Improvement
+
+* imprv: Render page tree item title as a real anchor (#11029) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix(editor): Editor breaks when an ID created by useId() is passed to a  reactstrap Popover/Tooltip targets (#11039) @yuki-takei
+* fix: Request requiring login is being made on the shared link page (#11026) @miya
+* fix: Search scope children as default setting is not applied (#11027) @miya
+* fix(slack): Normalize proxyUri to undefined for custom bot without proxy mode (#11035) @yuki-takei
+* fix: Avoid setState-in-render warning in useHydrateGlobalEachAtoms (#11023) @yuki-takei
+* fix: Stabilize watch-rendering-and-rescroll test suite (#11037) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Upgrade Biome (#11040) @yuki-takei
+
 ## [v7.5.1](https://github.com/growilabs/compare/v7.5.0...v7.5.1) - 2026-04-16
 ## [v7.5.1](https://github.com/growilabs/compare/v7.5.0...v7.5.1) - 2026-04-16
 
 
 ### 💎 Features
 ### 💎 Features

+ 23 - 0
apps/app/.claude/rules/ui-pitfalls.md

@@ -0,0 +1,23 @@
+# UI Pitfalls
+
+## useId() must not be passed to reactstrap `target` prop
+
+React's `useId()` generates IDs containing colons (`:r0:`, `:r1:`, `:r2:`). These are valid HTML `id` attributes but **invalid CSS selectors**.
+
+reactstrap's `findDOMElements()` resolves string targets via `document.querySelectorAll(target)`, which throws `DOMException: is not a valid selector` when the string contains colons.
+
+```tsx
+// ❌ WRONG: useId() output passed as string target
+const popoverTargetId = useId();
+<button id={popoverTargetId}>...</button>
+<Popover target={popoverTargetId} />  // → DOMException at componentDidMount
+
+// ✅ CORRECT: use ref — reactstrap resolves refs via .current, bypassing querySelectorAll
+const popoverTargetRef = useRef<HTMLButtonElement>(null);
+<button ref={popoverTargetRef}>...</button>
+<Popover target={popoverTargetRef} />
+```
+
+**Applies to all reactstrap components with a `target` prop**: `Popover`, `Tooltip`, `UncontrolledPopover`, `UncontrolledTooltip`, etc.
+
+**Safe uses of `useId()`**: `id=`, `htmlFor=`, `aria-labelledby=`, `aria-describedby=` — these use `getElementById` internally, which does not parse CSS.

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

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`7.5.1`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.5.1/apps/app/docker/Dockerfile)
+* [`7.5.2`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.5.2/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 
 

+ 17 - 5
apps/app/package.json

@@ -1,10 +1,11 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.5.2-RC.0",
+  "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",
@@ -16,16 +17,19 @@
     "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,6 +49,8 @@
     "//// 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",
@@ -109,11 +115,13 @@
     "@opentelemetry/sdk-node": "^0.202.0",
     "@opentelemetry/sdk-node": "^0.202.0",
     "@opentelemetry/sdk-trace-node": "^2.0.1",
     "@opentelemetry/sdk-trace-node": "^2.0.1",
     "@opentelemetry/semantic-conventions": "^1.34.0",
     "@opentelemetry/semantic-conventions": "^1.34.0",
+    "@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",
@@ -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",

+ 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 - 6
apps/app/src/client/components/Admin/AdminHome/AdminHome.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import { useId, useState } from 'react';
+import { useRef, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { Tooltip } from 'reactstrap';
 import { Tooltip } from 'reactstrap';
@@ -29,9 +29,7 @@ export const AdminHome: FC = () => {
     }, 500);
     }, 500);
   };
   };
 
 
-  // Generate CSS-safe ID by removing colons from useId() result
-  const copyButtonIdRaw = useId();
-  const copyButtonId = `copy-button-${copyButtonIdRaw.replace(/:/g, '')}`;
+  const copyIconRef = useRef<HTMLSpanElement>(null);
 
 
   return (
   return (
     <div data-testid="admin-home">
     <div data-testid="admin-home">
@@ -131,7 +129,7 @@ export const AdminHome: FC = () => {
                   onClick={(e) => e.preventDefault()}
                   onClick={(e) => e.preventDefault()}
                 >
                 >
                   <span
                   <span
-                    id={copyButtonId}
+                    ref={copyIconRef}
                     className="material-symbols-outlined"
                     className="material-symbols-outlined"
                     aria-hidden="true"
                     aria-hidden="true"
                   >
                   >
@@ -143,7 +141,7 @@ export const AdminHome: FC = () => {
               <Tooltip
               <Tooltip
                 placement="bottom"
                 placement="bottom"
                 isOpen={copyState === COPY_STATE.DONE}
                 isOpen={copyState === COPY_STATE.DONE}
-                target={copyButtonId}
+                target={copyIconRef}
                 fade={false}
                 fade={false}
               >
               >
                 {t('admin:admin_top:copy_prefilled_host_information:done')}
                 {t('admin:admin_top:copy_prefilled_host_information:done')}

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

@@ -110,8 +110,8 @@ const WarnForGroups = ({ errors }: WarnForGroupsProps): JSX.Element => {
   return (
   return (
     <div className="alert alert-warning">
     <div className="alert alert-warning">
       <ul>
       <ul>
-        {errors.map((error, index) => {
-          return <li key={`${error.message}-${index}`}>{error.message}</li>;
+        {errors.map((error) => {
+          return <li key={error.message}>{error.message}</li>;
         })}
         })}
       </ul>
       </ul>
     </div>
     </div>

+ 3 - 4
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -371,7 +371,7 @@ export const PageItemControlSubstance = (
 
 
   const {
   const {
     data: fetchedPageInfo,
     data: fetchedPageInfo,
-    error: fetchError,
+    isLoading: isFetchLoading,
     mutate: mutatePageInfo,
     mutate: mutatePageInfo,
   } = useSWRxPageInfo(shouldFetch ? pageId : null);
   } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
 
@@ -399,9 +399,8 @@ export const PageItemControlSubstance = (
     [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch],
     [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch],
   );
   );
 
 
-  // isLoading should be true only when fetching is in progress (data and error are both undefined)
-  const isLoading =
-    shouldFetch && fetchedPageInfo == null && fetchError == null;
+  // Delegate to SWR's isLoading so that a skipped request (null key) is not treated as loading
+  const isLoading = shouldFetch && isFetchLoading;
   const isDataUnavailable =
   const isDataUnavailable =
     !isLoading && fetchedPageInfo == null && presetPageInfo == null;
     !isLoading && fetchedPageInfo == null && presetPageInfo == null;
 
 

+ 5 - 8
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -147,10 +147,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       if (errors == null || errors.length === 0) return <></>;
       if (errors == null || errors.length === 0) return <></>;
       return (
       return (
         <div className="alert alert-danger">
         <div className="alert alert-danger">
-          {errors.map((err, index) => {
+          {errors.map((err) => {
             return (
             return (
               <small
               <small
-                key={`${err.code}-${index}`}
+                key={err.code}
                 // biome-ignore lint/security/noDangerouslySetInnerHtml: rendered HTML from translations
                 // biome-ignore lint/security/noDangerouslySetInnerHtml: rendered HTML from translations
                 dangerouslySetInnerHTML={{
                 dangerouslySetInnerHTML={{
                   __html: tWithOpt(err.message, err.args),
                   __html: tWithOpt(err.message, err.args),
@@ -171,10 +171,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       return (
       return (
         <ul className="alert alert-danger">
         <ul className="alert alert-danger">
           {errors.map((err, index) => (
           {errors.map((err, index) => (
-            <small
-              key={`${err.message}-${index}`}
-              className={index > 0 ? 'mt-1' : ''}
-            >
+            <small key={err.message} className={index > 0 ? 'mt-1' : ''}>
               {tWithOpt(err.message, err.args)}
               {tWithOpt(err.message, err.args)}
             </small>
             </small>
           ))}
           ))}
@@ -394,8 +391,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
 
         {registerErrors != null && registerErrors.length > 0 && (
         {registerErrors != null && registerErrors.length > 0 && (
           <p className="alert alert-danger">
           <p className="alert alert-danger">
-            {registerErrors.map((err, index) => (
-              <span key={`${err.message}-${index}`}>
+            {registerErrors.map((err) => (
+              <span key={err.message}>
                 {tWithOpt(err.message, err.args)}
                 {tWithOpt(err.message, err.args)}
                 <br />
                 <br />
               </span>
               </span>

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

@@ -1,4 +1,4 @@
-import { type FC, useId, useState } from 'react';
+import { type FC, useRef, useState } from 'react';
 import type { EditingClient } from '@growi/editor';
 import type { EditingClient } from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { Popover, PopoverBody } from 'reactstrap';
 import { Popover, PopoverBody } from 'reactstrap';
@@ -31,7 +31,7 @@ const AvatarWrapper: FC<{
 };
 };
 
 
 export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
 export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
-  const popoverTargetId = useId();
+  const popoverTargetRef = useRef<HTMLButtonElement>(null);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
@@ -56,7 +56,7 @@ export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
           <div className="ms-1">
           <div className="ms-1">
             <button
             <button
               type="button"
               type="button"
-              id={popoverTargetId}
+              ref={popoverTargetRef}
               className="btn border-0 bg-info-subtle rounded-pill p-0"
               className="btn border-0 bg-info-subtle rounded-pill p-0"
               onClick={togglePopover}
               onClick={togglePopover}
             >
             >
@@ -67,7 +67,7 @@ export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
             <Popover
             <Popover
               placement="bottom"
               placement="bottom"
               isOpen={isPopoverOpen}
               isOpen={isPopoverOpen}
-              target={popoverTargetId}
+              target={popoverTargetRef}
               toggle={togglePopover}
               toggle={togglePopover}
               trigger="legacy"
               trigger="legacy"
             >
             >

+ 2 - 2
apps/app/src/client/components/PageRenameModal/PageRenameModal.tsx

@@ -57,7 +57,7 @@ const PageRenameModalSubstance: React.FC = () => {
 
 
   const [errs, setErrs] = useState(null);
   const [errs, setErrs] = useState(null);
 
 
-  const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [_subordinatedPages, setSubordinatedPages] = useState([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isRenameRecursively, setIsRenameRecursively] = useState(true);
   const [isRenameRecursively, setIsRenameRecursively] = useState(true);
   const [isRenameRedirect, setIsRenameRedirect] = useState(false);
   const [isRenameRedirect, setIsRenameRedirect] = useState(false);
@@ -201,7 +201,7 @@ const PageRenameModalSubstance: React.FC = () => {
     };
     };
 
 
     return debounce(1000, checkIsPagePathRenameable);
     return debounce(1000, checkIsPagePathRenameable);
-  }, [isUsersHomepage]);
+  }, []);
 
 
   useEffect(() => {
   useEffect(() => {
     if (isOpened && page != null && pageNameInput !== page.data.path) {
     if (isOpened && page != null && pageNameInput !== page.data.path) {

+ 1 - 0
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -173,6 +173,7 @@ export const PageTreeItem: FC<TreeItemProps> = ({
       isWipPageShown={isWipPageShown}
       isWipPageShown={isWipPageShown}
       isEnableActions={isEnableActions}
       isEnableActions={isEnableActions}
       isReadOnlyUser={isReadOnlyUser}
       isReadOnlyUser={isReadOnlyUser}
+      asLink
       onClick={itemSelectedHandler}
       onClick={itemSelectedHandler}
       onWheelClick={itemSelectedByWheelClickHandler}
       onWheelClick={itemSelectedByWheelClickHandler}
       onToggle={onToggle}
       onToggle={onToggle}

+ 39 - 14
apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx

@@ -1,8 +1,24 @@
+import { setImmediate as realSetImmediate } from 'node:timers/promises';
 import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
 
 import { watchRenderingAndReScroll } from './watch-rendering-and-rescroll';
 import { watchRenderingAndReScroll } from './watch-rendering-and-rescroll';
 
 
+// happy-dom captures the real setTimeout at module load, before vitest's
+// fake timers are installed. Its MutationObserver callbacks therefore fire
+// on the REAL event loop, not on the fake timer clock. Yielding to the real
+// event loop (via node:timers/promises) flushes them.
+// Yield twice because happy-dom batches zero-delay timeouts and dispatch
+// across two real-timer ticks (the batcher + the listener's own timer).
+// NOTE: happy-dom v15 stores the MO listener callback in a `WeakRef`
+// (see MutationObserverListener.cjs), so GC between `observe()` and the
+// first mutation can silently drop delivery. Tests that assert MO-driven
+// behavior should rely on retry to absorb that rare collection window.
+const flushMutationObservers = async () => {
+  await realSetImmediate();
+  await realSetImmediate();
+};
+
 describe('watchRenderingAndReScroll', () => {
 describe('watchRenderingAndReScroll', () => {
   let container: HTMLDivElement;
   let container: HTMLDivElement;
   let scrollToTarget: ReturnType<typeof vi.fn>;
   let scrollToTarget: ReturnType<typeof vi.fn>;
@@ -56,7 +72,7 @@ describe('watchRenderingAndReScroll', () => {
     // Trigger a DOM mutation mid-timer
     // Trigger a DOM mutation mid-timer
     const child = document.createElement('span');
     const child = document.createElement('span');
     container.appendChild(child);
     container.appendChild(child);
-    await vi.advanceTimersByTimeAsync(0);
+    await flushMutationObservers();
 
 
     // The timer should NOT have been reset — 2 more seconds should fire it
     // The timer should NOT have been reset — 2 more seconds should fire it
     vi.advanceTimersByTime(2000);
     vi.advanceTimersByTime(2000);
@@ -65,7 +81,10 @@ describe('watchRenderingAndReScroll', () => {
     cleanup();
     cleanup();
   });
   });
 
 
-  it('should detect rendering elements added after initial check via observer', async () => {
+  // Retry absorbs rare happy-dom MO WeakRef GC drops (see file-top note).
+  it('should detect rendering elements added after initial check via observer', {
+    retry: 3,
+  }, async () => {
     const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
     const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
 
 
     vi.advanceTimersByTime(3000);
     vi.advanceTimersByTime(3000);
@@ -76,10 +95,9 @@ describe('watchRenderingAndReScroll', () => {
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl);
     container.appendChild(renderingEl);
 
 
-    // Flush microtasks so MutationObserver callback fires
-    await vi.advanceTimersByTimeAsync(0);
+    // Flush MO so it schedules the poll timer
+    await flushMutationObservers();
 
 
-    // Timer should be scheduled — fires after 5s
     await vi.advanceTimersByTimeAsync(5000);
     await vi.advanceTimersByTimeAsync(5000);
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
 
 
@@ -154,7 +172,10 @@ describe('watchRenderingAndReScroll', () => {
     expect(scrollToTarget).not.toHaveBeenCalled();
     expect(scrollToTarget).not.toHaveBeenCalled();
   });
   });
 
 
-  it('should not schedule further re-scrolls after rendering elements complete', async () => {
+  // Retry absorbs rare happy-dom MO WeakRef GC drops (see file-top note).
+  it('should perform a final re-scroll when rendering completes after the first poll', {
+    retry: 3,
+  }, async () => {
     const renderingEl = document.createElement('div');
     const renderingEl = document.createElement('div');
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl);
     container.appendChild(renderingEl);
@@ -165,13 +186,16 @@ describe('watchRenderingAndReScroll', () => {
     vi.advanceTimersByTime(5000);
     vi.advanceTimersByTime(5000);
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
 
 
-    // Rendering completes — attribute toggled to false
+    // Rendering completes — attribute toggled to false. MO observes the
+    // transition and triggers a final re-scroll to compensate for the
+    // trailing layout shift.
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
-    await vi.advanceTimersByTimeAsync(0);
+    await flushMutationObservers();
+    expect(scrollToTarget).toHaveBeenCalledTimes(2);
 
 
-    // No further re-scrolls should be scheduled
+    // No further scrolls afterward — the MO cleared the next poll timer.
     vi.advanceTimersByTime(10000);
     vi.advanceTimersByTime(10000);
-    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+    expect(scrollToTarget).toHaveBeenCalledTimes(2);
 
 
     cleanup();
     cleanup();
   });
   });
@@ -183,12 +207,13 @@ describe('watchRenderingAndReScroll', () => {
 
 
     const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
     const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
 
 
-    // Rendering completes before the first poll timer fires
+    // Rendering completes before the first poll timer fires (no async flush,
+    // so the MO does not deliver before the timer).
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
 
 
-    // Poll timer fires at 5s — detects no rendering elements.
-    // wasRendering is reset in the timer callback BEFORE scrollToTarget so that
-    // the subsequent checkAndSchedule call does not trigger a redundant extra scroll.
+    // wasRendering is reset in the timer callback BEFORE scrollToTarget so
+    // the subsequent checkAndSchedule call does not trigger a redundant
+    // extra scroll.
     vi.advanceTimersByTime(5000);
     vi.advanceTimersByTime(5000);
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
 
 

+ 3 - 1
apps/app/src/client/util/watch-rendering-and-rescroll.ts

@@ -8,7 +8,9 @@ export const WATCH_TIMEOUT_MS = 10000;
 
 
 /**
 /**
  * Watch for elements with in-progress rendering status in the container.
  * Watch for elements with in-progress rendering status in the container.
- * Periodically calls scrollToTarget while rendering elements remain.
+ * Periodically calls scrollToTarget while rendering elements remain, and
+ * performs a final re-scroll when the last rendering element completes
+ * to compensate for the trailing layout shift (Requirements 3.1–3.3).
  * Returns a cleanup function that stops observation and clears timers.
  * Returns a cleanup function that stops observation and clears timers.
  */
  */
 export const watchRenderingAndReScroll = (
 export const watchRenderingAndReScroll = (

+ 1 - 1
apps/app/src/components/utils/use-lazy-loader.ts

@@ -63,7 +63,7 @@ export const useLazyLoader = <T extends Record<string, unknown>>(
     null,
     null,
   );
   );
 
 
-  const memoizedImportFn = useCallback(importFn, []);
+  const memoizedImportFn = useCallback(importFn, [importFn]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (isActive && !Component) {
     if (isActive && !Component) {

+ 4 - 1
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -2,6 +2,7 @@ import type { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import { useIsGuestUser } from '~/states/context';
 
 
 import type { AccessibleAiAssistantsHasId } from '../../interfaces/ai-assistant';
 import type { AccessibleAiAssistantsHasId } from '../../interfaces/ai-assistant';
 
 
@@ -9,8 +10,10 @@ export const useSWRxAiAssistants = (): SWRResponse<
   AccessibleAiAssistantsHasId,
   AccessibleAiAssistantsHasId,
   Error
   Error
 > => {
 > => {
+  const isGuestUser = useIsGuestUser();
+
   return useSWRImmutable<AccessibleAiAssistantsHasId>(
   return useSWRImmutable<AccessibleAiAssistantsHasId>(
-    ['/openai/ai-assistants'],
+    !isGuestUser ? ['/openai/ai-assistants'] : null,
     ([endpoint]) =>
     ([endpoint]) =>
       apiv3Get(endpoint).then(
       apiv3Get(endpoint).then(
         (response) => response.data.accessibleAiAssistants,
         (response) => response.data.accessibleAiAssistants,

+ 8 - 7
apps/app/src/features/openai/server/services/client-delegator/get-client.ts

@@ -8,13 +8,14 @@ type GetDelegatorOptions = {
 };
 };
 
 
 type IsAny<T> = 'dummy' extends T & 'dummy' ? true : false;
 type IsAny<T> = 'dummy' extends T & 'dummy' ? true : false;
-type Delegator<Opts extends GetDelegatorOptions> = IsAny<Opts> extends true
-  ? IOpenaiClientDelegator
-  : Opts extends { openaiServiceType: 'openai' }
-    ? OpenaiClientDelegator
-    : Opts extends { openaiServiceType: 'azure-openai' }
-      ? AzureOpenaiClientDelegator
-      : IOpenaiClientDelegator;
+type Delegator<Opts extends GetDelegatorOptions> =
+  IsAny<Opts> extends true
+    ? IOpenaiClientDelegator
+    : Opts extends { openaiServiceType: 'openai' }
+      ? OpenaiClientDelegator
+      : Opts extends { openaiServiceType: 'azure-openai' }
+        ? AzureOpenaiClientDelegator
+        : IOpenaiClientDelegator;
 
 
 // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
 // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
 let instance;
 let instance;

+ 47 - 19
apps/app/src/features/page-tree/components/SimpleItemContent.tsx

@@ -1,4 +1,6 @@
-import { useId } from 'react';
+import { useRef } from 'react';
+import Link from 'next/link';
+import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import path from 'pathe';
 import path from 'pathe';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -12,8 +14,10 @@ const moduleClass = styles['simple-item-content'] ?? '';
 
 
 export const SimpleItemContent = ({
 export const SimpleItemContent = ({
   page,
   page,
+  asLink = false,
 }: {
 }: {
   page: IPageForItem;
   page: IPageForItem;
+  asLink?: boolean;
 }): JSX.Element => {
 }): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -22,41 +26,65 @@ export const SimpleItemContent = ({
   const shouldShowAttentionIcon =
   const shouldShowAttentionIcon =
     page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
     page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
 
-  const spanId = `path-recovery-${useId()}`;
+  const warningIconRef = useRef<HTMLSpanElement>(null);
+
+  // When asLink is true, render the title as an anchor so that the browser
+  // recognizes it as a link (enables Ctrl/Cmd+click to open in new tab,
+  // middle-click, and the right-click "Open link in new tab" context menu).
+  // Otherwise we render a plain div and let the surrounding <li> capture
+  // clicks via JS (existing non-navigation usages such as modals).
+  const href =
+    asLink && page.path != null && page._id != null
+      ? pathUtils.returnPathForURL(page.path, page._id)
+      : undefined;
+
+  const titleClassName = `grw-page-title-anchor flex-grow-1 text-truncate ${page.isEmpty ? 'opacity-75' : ''}`;
 
 
   return (
   return (
     <div
     <div
-      className={`${moduleClass} flex-grow-1 d-flex align-items-center pe-none`}
+      className={`${moduleClass} flex-grow-1 d-flex align-items-center ${href != null ? '' : 'pe-none'}`}
       style={{ minWidth: 0 }}
       style={{ minWidth: 0 }}
     >
     >
       {shouldShowAttentionIcon && (
       {shouldShowAttentionIcon && (
         <>
         <>
           <span
           <span
-            id={spanId}
+            ref={warningIconRef}
             className="material-symbols-outlined mr-2 text-warning"
             className="material-symbols-outlined mr-2 text-warning"
           >
           >
             warning
             warning
           </span>
           </span>
-          <UncontrolledTooltip placement="top" target={spanId} fade={false}>
+          <UncontrolledTooltip
+            placement="top"
+            target={warningIconRef}
+            fade={false}
+          >
             {t('tooltip.operation.attention.rename')}
             {t('tooltip.operation.attention.rename')}
           </UncontrolledTooltip>
           </UncontrolledTooltip>
         </>
         </>
       )}
       )}
-      {page != null && page.path != null && page._id != null && (
-        <div className="grw-page-title-anchor flex-grow-1">
-          <div className="d-flex align-items-center">
-            <span
-              className={`text-truncate me-1 ${page.isEmpty && 'opacity-75'}`}
-            >
-              {pageName}
-            </span>
-            {page.wip && (
-              <span className="wip-page-badge badge rounded-pill me-1 text-bg-secondary">
-                WIP
-              </span>
-            )}
+      {page != null &&
+        page.path != null &&
+        page._id != null &&
+        (href != null ? (
+          <Link
+            href={href}
+            prefetch={false}
+            className={`${titleClassName} text-reset`}
+            style={{ minWidth: 0 }}
+          >
+            {pageName}
+          </Link>
+        ) : (
+          <div className={titleClassName} style={{ minWidth: 0 }}>
+            {pageName}
           </div>
           </div>
-        </div>
+        ))}
+      {/* WIP is a status indicator — kept outside the link so it is not
+          read as part of the anchor's accessible name, and not truncated. */}
+      {page.wip && (
+        <span className="wip-page-badge badge rounded-pill ms-1 text-bg-secondary flex-shrink-0">
+          WIP
+        </span>
       )}
       )}
     </div>
     </div>
   );
   );

+ 3 - 1
apps/app/src/features/page-tree/components/TreeItemLayout.tsx

@@ -12,6 +12,7 @@ const indentSize = 10; // in px
 
 
 type TreeItemLayoutProps = TreeItemProps & {
 type TreeItemLayoutProps = TreeItemProps & {
   className?: string;
   className?: string;
+  asLink?: boolean;
 };
 };
 
 
 export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
 export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
@@ -24,6 +25,7 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     isReadOnlyUser,
     isReadOnlyUser,
     isWipPageShown = true,
     isWipPageShown = true,
     showAlternativeContent,
     showAlternativeContent,
+    asLink,
     onRenamed,
     onRenamed,
     onClick,
     onClick,
     onClickDuplicateMenuItem,
     onClickDuplicateMenuItem,
@@ -142,7 +144,7 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
           ))
           ))
         ) : (
         ) : (
           <>
           <>
-            <SimpleItemContent page={page} />
+            <SimpleItemContent page={page} asLink={asLink} />
             <div className="d-hover-none">
             <div className="d-hover-none">
               {EndComponents?.map((EndComponent, index) => (
               {EndComponents?.map((EndComponent, index) => (
                 // biome-ignore lint/suspicious/noArrayIndexKey: static component list
                 // biome-ignore lint/suspicious/noArrayIndexKey: static component list

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

+ 23 - 3
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -4,9 +4,12 @@ import Downshift, {
   type DownshiftState,
   type DownshiftState,
   type StateChangeOptions,
   type StateChangeOptions,
 } from 'downshift';
 } from 'downshift';
+import { useAtomValue } from 'jotai';
 import { Modal, ModalBody } from 'reactstrap';
 import { Modal, ModalBody } from 'reactstrap';
 
 
+import { useCurrentPagePath } from '~/states/page';
 import { useSetSearchKeyword } from '~/states/search';
 import { useSetSearchKeyword } from '~/states/search';
+import { isSearchScopeChildrenAsDefaultAtom } from '~/states/server-configurations';
 
 
 import { isIncludeAiMenthion, removeAiMenthion } from '../../utils/ai';
 import { isIncludeAiMenthion, removeAiMenthion } from '../../utils/ai';
 import type { DownshiftItem } from '../interfaces/downshift';
 import type { DownshiftItem } from '../interfaces/downshift';
@@ -164,17 +167,34 @@ const SearchModal = (): JSX.Element => {
   const { close: closeSearchModal } = useSearchModalActions();
   const { close: closeSearchModal } = useSearchModalActions();
 
 
   const setSearchKeyword = useSetSearchKeyword();
   const setSearchKeyword = useSetSearchKeyword();
+  const isSearchScopeChildrenAsDefault = useAtomValue(
+    isSearchScopeChildrenAsDefaultAtom,
+  );
+  const currentPagePath = useCurrentPagePath();
 
 
   const searchHandler = useCallback(
   const searchHandler = useCallback(
     (keyword: string) => {
     (keyword: string) => {
       // invoke override function if exists
       // invoke override function if exists
       if (onSearchOverride != null) {
       if (onSearchOverride != null) {
         onSearchOverride(keyword);
         onSearchOverride(keyword);
-      } else {
-        setSearchKeyword(keyword);
+        return;
       }
       }
+
+      // Respect the admin setting: scope search to the current page tree by default
+      const shouldScopeToChildren =
+        isSearchScopeChildrenAsDefault && currentPagePath != null;
+      const finalKeyword = shouldScopeToChildren
+        ? `prefix:${currentPagePath} ${keyword}`
+        : keyword;
+
+      setSearchKeyword(finalKeyword);
     },
     },
-    [onSearchOverride, setSearchKeyword],
+    [
+      onSearchOverride,
+      setSearchKeyword,
+      isSearchScopeChildrenAsDefault,
+      currentPagePath,
+    ],
   );
   );
 
 
   return (
   return (

+ 1 - 4
apps/app/src/pages/admin/_shared/layout.tsx

@@ -28,10 +28,7 @@ export function createAdminPageLayout<P extends AdminCommonProps>(
           : options.title;
           : options.title;
       const title = useCustomTitle(rawTitle);
       const title = useCustomTitle(rawTitle);
 
 
-      const factories = useMemo(
-        () => options.containerFactories ?? [],
-        [options.containerFactories],
-      );
+      const factories = useMemo(() => options.containerFactories ?? [], []);
       const containers = useUnstatedContainers(factories);
       const containers = useUnstatedContainers(factories);
 
 
       return (
       return (

+ 1 - 3
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -45,9 +45,7 @@ const validator = {
   ),
   ),
   pageIdsOrPathRequired: [
   pageIdsOrPathRequired: [
     // type check independent of existence check
     // type check independent of existence check
-    query('pageIds')
-      .isArray()
-      .optional(),
+    query('pageIds').isArray().optional(),
     query('path').isString().optional(),
     query('path').isString().optional(),
     // existence check
     // existence check
     oneOf(
     oneOf(

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

+ 3 - 9
apps/app/src/server/routes/apiv3/share-links.js

@@ -111,9 +111,7 @@ module.exports = (crowi) => {
 
 
   validator.getShareLinks = [
   validator.getShareLinks = [
     // validate the page id is MongoId
     // validate the page id is MongoId
-    query('relatedPage')
-      .isMongoId()
-      .withMessage('Page Id is required'),
+    query('relatedPage').isMongoId().withMessage('Page Id is required'),
   ];
   ];
 
 
   /**
   /**
@@ -178,9 +176,7 @@ module.exports = (crowi) => {
 
 
   validator.shareLinkStatus = [
   validator.shareLinkStatus = [
     // validate the page id is MongoId
     // validate the page id is MongoId
-    body('relatedPage')
-      .isMongoId()
-      .withMessage('Page Id is required'),
+    body('relatedPage').isMongoId().withMessage('Page Id is required'),
     // validate expireation date is not empty, is not before today and is date.
     // validate expireation date is not empty, is not before today and is date.
     body('expiredAt')
     body('expiredAt')
       .if((value) => value != null)
       .if((value) => value != null)
@@ -268,9 +264,7 @@ module.exports = (crowi) => {
 
 
   validator.deleteShareLinks = [
   validator.deleteShareLinks = [
     // validate the page id is MongoId
     // validate the page id is MongoId
-    query('relatedPage')
-      .isMongoId()
-      .withMessage('Page Id is required'),
+    query('relatedPage').isMongoId().withMessage('Page Id is required'),
   ];
   ];
 
 
   /**
   /**

+ 1 - 1
apps/app/src/server/routes/apiv3/slack-integration.js

@@ -280,7 +280,7 @@ module.exports = (crowi) => {
   };
   };
 
 
   function getRespondUtil(responseUrl) {
   function getRespondUtil(responseUrl) {
-    const proxyUri = slackIntegrationService.proxyUriForCurrentType ?? null; // can be null
+    const proxyUri = slackIntegrationService.proxyUriForCurrentType;
 
 
     const appSiteUrl = growiInfoService.getSiteUrl();
     const appSiteUrl = growiInfoService.getSiteUrl();
     if (appSiteUrl == null || appSiteUrl === '') {
     if (appSiteUrl == null || appSiteUrl === '') {

+ 1 - 1
apps/app/src/server/service/config-manager/config-definition.ts

@@ -1115,7 +1115,7 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'SLACKBOT_TYPE',
     envVarName: 'SLACKBOT_TYPE',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
-  'slackbot:proxyUri': defineConfig<string | undefined>({
+  'slackbot:proxyUri': defineConfig<NonBlankString | undefined>({
     envVarName: 'SLACKBOT_INTEGRATION_PROXY_URI',
     envVarName: 'SLACKBOT_INTEGRATION_PROXY_URI',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),

+ 16 - 13
apps/app/src/server/service/file-uploader/multipart-uploader.spec.ts

@@ -112,20 +112,23 @@ describe('MultipartUploader', () => {
         },
         },
       ];
       ];
 
 
-      describe.each(cases)(
-        'When current status is $current and desired status is $desired',
-        ({ current, desired, errorMessage }) => {
-          beforeEach(() => {
-            uploader.setCurrentStatus(current);
-          });
+      describe.each(
+        cases,
+      )('When current status is $current and desired status is $desired', ({
+        current,
+        desired,
+        errorMessage,
+      }) => {
+        beforeEach(() => {
+          uploader.setCurrentStatus(current);
+        });
 
 
-          it(`should throw expected error: "${errorMessage}"`, () => {
-            expect(() => uploader.testValidateUploadStatus(desired)).toThrow(
-              errorMessage,
-            );
-          });
-        },
-      );
+        it(`should throw expected error: "${errorMessage}"`, () => {
+          expect(() => uploader.testValidateUploadStatus(desired)).toThrow(
+            errorMessage,
+          );
+        });
+      });
     });
     });
   });
   });
 });
 });

+ 10 - 9
apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts

@@ -25,15 +25,16 @@ type GetDelegatorOptions =
     };
     };
 
 
 type IsAny<T> = 'dummy' extends T & 'dummy' ? true : false;
 type IsAny<T> = 'dummy' extends T & 'dummy' ? true : false;
-type Delegator<Opts extends GetDelegatorOptions> = IsAny<Opts> extends true
-  ? ElasticsearchClientDelegator
-  : Opts extends { version: 7 }
-    ? ES7ClientDelegator
-    : Opts extends { version: 8 }
-      ? ES8ClientDelegator
-      : Opts extends { version: 9 }
-        ? ES9ClientDelegator
-        : ElasticsearchClientDelegator;
+type Delegator<Opts extends GetDelegatorOptions> =
+  IsAny<Opts> extends true
+    ? ElasticsearchClientDelegator
+    : Opts extends { version: 7 }
+      ? ES7ClientDelegator
+      : Opts extends { version: 8 }
+        ? ES8ClientDelegator
+        : Opts extends { version: 9 }
+          ? ES9ClientDelegator
+          : ElasticsearchClientDelegator;
 
 
 let instance: ElasticsearchClientDelegator | null = null;
 let instance: ElasticsearchClientDelegator | null = null;
 export const getClient = async <Opts extends GetDelegatorOptions>(
 export const getClient = async <Opts extends GetDelegatorOptions>(

+ 11 - 10
apps/app/src/server/service/slack-integration.ts

@@ -1,3 +1,7 @@
+import {
+  type NonBlankString,
+  toNonBlankStringOrUndefined,
+} from '@growi/core/dist/interfaces';
 import {
 import {
   type GrowiBotEvent,
   type GrowiBotEvent,
   type GrowiCommand,
   type GrowiCommand,
@@ -25,7 +29,8 @@ import { LinkSharedEventHandler } from './slack-event-handler/link-shared';
 
 
 const logger = loggerFactory('growi:service:SlackBotService');
 const logger = loggerFactory('growi:service:SlackBotService');
 
 
-const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
+const OFFICIAL_SLACKBOT_PROXY_URI =
+  'https://slackbot-proxy.growi.org' as NonBlankString;
 
 
 type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
 type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
 
 
@@ -127,23 +132,19 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     return true;
     return true;
   }
   }
 
 
-  get proxyUriForCurrentType(): string | undefined {
+  get proxyUriForCurrentType(): NonBlankString | undefined {
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
 
 
     // TODO assert currentBotType is not null and CUSTOM_WITHOUT_PROXY
     // TODO assert currentBotType is not null and CUSTOM_WITHOUT_PROXY
 
 
-    let proxyUri: string | undefined;
-
     switch (currentBotType) {
     switch (currentBotType) {
       case SlackbotType.OFFICIAL:
       case SlackbotType.OFFICIAL:
-        proxyUri = OFFICIAL_SLACKBOT_PROXY_URI;
-        break;
+        return OFFICIAL_SLACKBOT_PROXY_URI;
       default:
       default:
-        proxyUri = configManager.getConfig('slackbot:proxyUri');
-        break;
+        return toNonBlankStringOrUndefined(
+          configManager.getConfig('slackbot:proxyUri'),
+        );
     }
     }
-
-    return proxyUri;
   }
   }
 
 
   /**
   /**

+ 9 - 10
apps/app/src/server/util/is-simple-request.spec.ts

@@ -88,16 +88,15 @@ describe('isSimpleRequest', () => {
         'X-Requested-With',
         'X-Requested-With',
         'X-CSRF-Token',
         'X-CSRF-Token',
       ];
       ];
-      it.each(unsafeHeaders)(
-        'returns false for unsafe header: %s',
-        (headerName) => {
-          const reqMock = mock<Request>({
-            method: 'POST',
-            headers: { [headerName]: 'test-value' },
-          });
-          expect(isSimpleRequest(reqMock)).toBe(false);
-        },
-      );
+      it.each(
+        unsafeHeaders,
+      )('returns false for unsafe header: %s', (headerName) => {
+        const reqMock = mock<Request>({
+          method: 'POST',
+          headers: { [headerName]: 'test-value' },
+        });
+        expect(isSimpleRequest(reqMock)).toBe(false);
+      });
       // combination
       // combination
       it('returns false when safe and unsafe headers are mixed', () => {
       it('returns false when safe and unsafe headers are mixed', () => {
         const reqMock = mock<Request>();
         const reqMock = mock<Request>();

+ 1 - 0
apps/app/src/server/util/safe-path-utils.ts

@@ -1,5 +1,6 @@
 import { AllLang } from '@growi/core';
 import { AllLang } from '@growi/core';
 import path from 'pathe';
 import path from 'pathe';
+
 export { AllLang as SUPPORTED_LOCALES };
 export { AllLang as SUPPORTED_LOCALES };
 
 
 /**
 /**

+ 2 - 3
apps/app/src/services/renderer/recommended-whitelist.ts

@@ -3,9 +3,8 @@ import deepmerge from 'ts-deepmerge';
 
 
 type Attributes = typeof defaultSchema.attributes;
 type Attributes = typeof defaultSchema.attributes;
 
 
-type ExtractPropertyDefinition<T> = T extends Record<string, (infer U)[]>
-  ? U
-  : never;
+type ExtractPropertyDefinition<T> =
+  T extends Record<string, (infer U)[]> ? U : never;
 
 
 type PropertyDefinition = ExtractPropertyDefinition<NonNullable<Attributes>>;
 type PropertyDefinition = ExtractPropertyDefinition<NonNullable<Attributes>>;
 
 

+ 3 - 3
apps/app/src/states/ui/sidebar/sidebar.ts

@@ -66,10 +66,10 @@ export const useCurrentProductNavWidth = () => {
 
 
 // Export base atoms for SSR hydration
 // Export base atoms for SSR hydration
 export {
 export {
-  preferCollapsedModeAtom,
-  isCollapsedContentsOpenedAtom,
-  currentSidebarContentsAtom,
   currentProductNavWidthAtom,
   currentProductNavWidthAtom,
+  currentSidebarContentsAtom,
+  isCollapsedContentsOpenedAtom,
+  preferCollapsedModeAtom,
 };
 };
 
 
 const sidebarModeAtom = atom((get) => {
 const sidebarModeAtom = atom((get) => {

+ 11 - 4
apps/app/src/stores/page.tsx

@@ -90,6 +90,12 @@ export const mutateAllPageInfo = (): Promise<void[]> => {
   return mutate((key) => Array.isArray(key) && key[0] === '/page/info');
   return mutate((key) => Array.isArray(key) && key[0] === '/page/info');
 };
 };
 
 
+const hasShareLinkId = (
+  shareLinkId: string | null | undefined,
+): shareLinkId is string => {
+  return shareLinkId != null && shareLinkId.trim().length > 0;
+};
+
 /**
 /**
  * Build query params for /page/info endpoint.
  * Build query params for /page/info endpoint.
  * Only includes shareLinkId when it is a non-empty string.
  * Only includes shareLinkId when it is a non-empty string.
@@ -98,7 +104,7 @@ const buildPageInfoParams = (
   pageId: string,
   pageId: string,
   shareLinkId: string | null | undefined,
   shareLinkId: string | null | undefined,
 ): { pageId: string; shareLinkId?: string } => {
 ): { pageId: string; shareLinkId?: string } => {
-  if (shareLinkId != null && shareLinkId.trim().length > 0) {
+  if (hasShareLinkId(shareLinkId)) {
     return { pageId, shareLinkId };
     return { pageId, shareLinkId };
   }
   }
   return { pageId };
   return { pageId };
@@ -113,9 +119,10 @@ export const useSWRxPageInfo = (
   const isGuestUser = useIsGuestUser();
   const isGuestUser = useIsGuestUser();
 
 
   const key = useMemo(() => {
   const key = useMemo(() => {
-    return pageId != null
-      ? ['/page/info', pageId, shareLinkId, isGuestUser]
-      : null;
+    if (pageId == null) return null;
+    // Guests without a share link cannot access page info, so skip the request
+    if (isGuestUser && !hasShareLinkId(shareLinkId)) return null;
+    return ['/page/info', pageId, shareLinkId, isGuestUser];
   }, [shareLinkId, isGuestUser, pageId]);
   }, [shareLinkId, isGuestUser, pageId]);
 
 
   const swrResult = useSWRImmutable(
   const swrResult = useSWRImmutable(

+ 6 - 1
apps/app/src/stores/user.tsx

@@ -5,15 +5,20 @@ import useSWRImmutable from 'swr/immutable';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import { useIsGuestUser } from '~/states/context';
 import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user';
 import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user';
 
 
 export const useSWRxUsersList = (
 export const useSWRxUsersList = (
   userIds: string[],
   userIds: string[],
 ): SWRResponse<IUserHasId[], Error> => {
 ): SWRResponse<IUserHasId[], Error> => {
+  const isGuestUser = useIsGuestUser();
   const distinctUserIds =
   const distinctUserIds =
     userIds.length > 0 ? Array.from(new Set(userIds)).sort() : [];
     userIds.length > 0 ? Array.from(new Set(userIds)).sort() : [];
+
+  const shouldFetch = !isGuestUser && distinctUserIds.length > 0;
+
   return useSWR(
   return useSWR(
-    distinctUserIds.length > 0 ? ['/users/list', distinctUserIds] : null,
+    shouldFetch ? ['/users/list', distinctUserIds] : null,
     ([endpoint, userIds]) =>
     ([endpoint, userIds]) =>
       apiv3Get(endpoint, { userIds: userIds.join(',') }).then((response) => {
       apiv3Get(endpoint, { userIds: userIds.join(',') }).then((response) => {
         return response.data.users;
         return response.data.users;

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

+ 1 - 1
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": {

+ 7 - 14
biome.json

@@ -1,30 +1,23 @@
 {
 {
   "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
   "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
+  "vcs": {
+    "enabled": true,
+    "clientKind": "git",
+    "useIgnoreFile": true
+  },
   "files": {
   "files": {
     "includes": [
     "includes": [
       "**",
       "**",
-      "!**/.pnpm-store",
       "!**/.terraform",
       "!**/.terraform",
-      "!**/coverage",
-      "!**/dist",
-      "!**/.next",
-      "!**/node_modules",
-      "!**/vite.*.ts.timestamp-*",
       "!**/*.grit",
       "!**/*.grit",
       "!**/turbo.json",
       "!**/turbo.json",
       "!**/.devcontainer",
       "!**/.devcontainer",
       "!**/.stylelintrc.json",
       "!**/.stylelintrc.json",
       "!**/package.json",
       "!**/package.json",
-      "!**/*.vendor-styles.prebuilt.*",
-      "!.turbo",
       "!.vscode",
       "!.vscode",
       "!.claude",
       "!.claude",
       "!tsconfig.base.json",
       "!tsconfig.base.json",
-      "!apps/app/src/styles/prebuilt",
-      "!apps/app/next-env.d.ts",
-      "!apps/app/tmp",
-      "!apps/pdf-converter/specs",
-      "!apps/slackbot-proxy/src/public/bootstrap",
+      "!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"
     ]
     ]
@@ -85,7 +78,7 @@
       "correctness": {
       "correctness": {
         "useUniqueElementIds": "warn"
         "useUniqueElementIds": "warn"
       },
       },
-      "nursery": {
+      "complexity": {
         "useMaxParams": "warn"
         "useMaxParams": "warn"
       }
       }
     }
     }

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "7.5.2-RC.0",
+  "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,
@@ -41,7 +41,7 @@
   },
   },
   "// comments for devDependencies": {},
   "// comments for devDependencies": {},
   "devDependencies": {
   "devDependencies": {
-    "@biomejs/biome": "^2.2.6",
+    "@biomejs/biome": "^2.4.12",
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/cli": "^2.27.3",
     "@changesets/cli": "^2.27.3",
     "@faker-js/faker": "^9.0.1",
     "@faker-js/faker": "^9.0.1",

+ 1 - 8
packages/core/src/models/serializers/user-serializer.ts

@@ -13,14 +13,7 @@ export const omitInsecureAttributes = <U extends IUser>(
 ): IUserSerializedSecurely<U> => {
 ): IUserSerializedSecurely<U> => {
   const leanDoc = user instanceof Document ? user.toObject<U>() : user;
   const leanDoc = user instanceof Document ? user.toObject<U>() : user;
 
 
-  const {
-    // biome-ignore lint/correctness/noUnusedVariables: ignore
-    password,
-    // biome-ignore lint/correctness/noUnusedVariables: ignore
-    apiToken,
-    email,
-    ...rest
-  } = leanDoc;
+  const { password, apiToken, email, ...rest } = leanDoc;
 
 
   const secureUser: IUserSerializedSecurely<U> = rest;
   const secureUser: IUserSerializedSecurely<U> = rest;
 
 

+ 9 - 9
packages/core/src/utils/objectid-utils.spec.ts

@@ -4,16 +4,16 @@ import { isValidObjectId } from './objectid-utils';
 
 
 describe('isValidObjectId', () => {
 describe('isValidObjectId', () => {
   describe.concurrent.each`
   describe.concurrent.each`
-    arg                                           | expected
-    ${undefined}                                  | ${false}
-    ${null}                                       | ${false}
-    ${'geeks'}                                    | ${false}
-    ${'toptoptoptop'}                             | ${false}
-    ${'geeksfogeeks'}                             | ${false}
-    ${'594ced02ed345b2b049222c5'}                 | ${true}
-    ${new ObjectId('594ced02ed345b2b049222c5')}   | ${true}
+    arg                                         | expected
+    ${undefined}                                | ${false}
+    ${null}                                     | ${false}
+    ${'geeks'}                                  | ${false}
+    ${'toptoptoptop'}                           | ${false}
+    ${'geeksfogeeks'}                           | ${false}
+    ${'594ced02ed345b2b049222c5'}               | ${true}
+    ${new ObjectId('594ced02ed345b2b049222c5')} | ${true}
   `('should return $expected', ({ arg, expected }) => {
   `('should return $expected', ({ arg, expected }) => {
-    test(`when the argument is '${arg}'`, async () => {
+    test(`when the argument is '${arg}'`, () => {
       // when:
       // when:
       const result = isValidObjectId(arg);
       const result = isValidObjectId(arg);
 
 

+ 6 - 7
packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts

@@ -60,12 +60,11 @@ describe('generateChildrenRegExp', () => {
       expect(validPath).toMatch(result);
       expect(validPath).toMatch(result);
     });
     });
 
 
-    test.each(invalidPaths)(
-      'should not match invalid path: %s',
-      (invalidPath) => {
-        const result = generateChildrenRegExp(path);
-        expect(invalidPath).not.toMatch(result);
-      },
-    );
+    test.each(
+      invalidPaths,
+    )('should not match invalid path: %s', (invalidPath) => {
+      const result = generateChildrenRegExp(path);
+      expect(invalidPath).not.toMatch(result);
+    });
   });
   });
 });
 });

+ 29 - 41
packages/core/src/utils/page-path-utils/index.spec.ts

@@ -56,17 +56,14 @@ describe.concurrent('convertToNewAffiliationPath test', () => {
     expect(result === 'parent/child').toBe(false);
     expect(result === 'parent/child').toBe(false);
   });
   });
 
 
-  test.concurrent(
-    'Parent and Child path names are switched unexpectedly',
-    () => {
-      const result = convertToNewAffiliationPath(
-        'parent/',
-        'parent4/',
-        'parent/child',
-      );
-      expect(result === 'child/parent4').toBe(false);
-    },
-  );
+  test.concurrent('Parent and Child path names are switched unexpectedly', () => {
+    const result = convertToNewAffiliationPath(
+      'parent/',
+      'parent4/',
+      'parent/child',
+    );
+    expect(result === 'child/parent4').toBe(false);
+  });
 });
 });
 
 
 describe.concurrent('isCreatablePage test', () => {
 describe.concurrent('isCreatablePage test', () => {
@@ -149,41 +146,32 @@ describe.concurrent('isCreatablePage test', () => {
       );
       );
     });
     });
 
 
-    test.concurrent(
-      'Should omit when some paths are at duplicated area',
-      () => {
-        const paths = ['/A', '/A/A', '/A/B/A', '/B', '/B/A', '/AA'];
-        const expectedPaths = ['/A', '/B', '/AA'];
+    test.concurrent('Should omit when some paths are at duplicated area', () => {
+      const paths = ['/A', '/A/A', '/A/B/A', '/B', '/B/A', '/AA'];
+      const expectedPaths = ['/A', '/B', '/AA'];
 
 
-        expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
-          expectedPaths,
-        );
-      },
-    );
+      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
+        expectedPaths,
+      );
+    });
 
 
-    test.concurrent(
-      'Should omit when some long paths are at duplicated area',
-      () => {
-        const paths = ['/A/B/C', '/A/B/C/D', '/A/B/C/D/E'];
-        const expectedPaths = ['/A/B/C'];
+    test.concurrent('Should omit when some long paths are at duplicated area', () => {
+      const paths = ['/A/B/C', '/A/B/C/D', '/A/B/C/D/E'];
+      const expectedPaths = ['/A/B/C'];
 
 
-        expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
-          expectedPaths,
-        );
-      },
-    );
+      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
+        expectedPaths,
+      );
+    });
 
 
-    test.concurrent(
-      'Should omit when some long paths are at duplicated area [case insensitivity]',
-      () => {
-        const paths = ['/a/B/C', '/A/b/C/D', '/A/B/c/D/E'];
-        const expectedPaths = ['/a/B/C'];
+    test.concurrent('Should omit when some long paths are at duplicated area [case insensitivity]', () => {
+      const paths = ['/a/B/C', '/A/b/C/D', '/A/B/c/D/E'];
+      const expectedPaths = ['/a/B/C'];
 
 
-        expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
-          expectedPaths,
-        );
-      },
-    );
+      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
+        expectedPaths,
+      );
+    });
   });
   });
 
 
   describe.concurrent('Test getUsernameByPath', () => {
   describe.concurrent('Test getUsernameByPath', () => {

+ 1 - 0
packages/remark-growi-directive/src/index.js

@@ -8,4 +8,5 @@ export {
   TextGrowiPluginDirectiveData,
   TextGrowiPluginDirectiveData,
 } from './mdast-util-growi-directive';
 } from './mdast-util-growi-directive';
 
 
+// biome-ignore lint/style/noDefaultExport: remark plugins are conventionally consumed as default imports
 export default remarkGrowiDirectivePlugin;
 export default remarkGrowiDirectivePlugin;

+ 1 - 1
packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/factory-attributes.js

@@ -34,7 +34,7 @@ import {
  * @param {string} attributeValueData
  * @param {string} attributeValueData
  * @param {boolean} [disallowEol=false]
  * @param {boolean} [disallowEol=false]
  */
  */
-// biome-ignore lint/nursery/useMaxParams: This module is transplanted from micromark and we want to keep the signature same.
+// biome-ignore lint/complexity/useMaxParams: This module is transplanted from micromark and we want to keep the signature same.
 export function factoryAttributes(
 export function factoryAttributes(
   effects,
   effects,
   ok,
   ok,

+ 1 - 1
packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/factory-label.js

@@ -22,7 +22,7 @@ import { ok as assert } from 'uvu/assert';
  * @param {string} stringType
  * @param {string} stringType
  * @param {boolean} [disallowEol=false]
  * @param {boolean} [disallowEol=false]
  */
  */
-// biome-ignore lint/nursery/useMaxParams: This module is transplanted from micromark and we want to keep the signature same.
+// biome-ignore lint/complexity/useMaxParams: This module is transplanted from micromark and we want to keep the signature same.
 export function factoryLabel(
 export function factoryLabel(
   effects,
   effects,
   ok,
   ok,

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 350 - 67
pnpm-lock.yaml


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio