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

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

Yuki Takei 4 недель назад
Родитель
Сommit
7a9dc52faf
73 измененных файлов с 1634 добавлено и 900 удалено
  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
 
-Remove `phase/new` (if present) and add `phase/under-investigation`:
+Before applying any labels, fetch the exact label names from the repository:
 
 ```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`.
@@ -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):
 
 ```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
@@ -124,8 +134,9 @@ List specific files and changes needed, but do NOT apply them yet.}
 
 ### 4-B: Post Comment on Issue
 
-Detect the language of the issue body (from Step 1) and write the comment **in the same language**.
-For example, if the issue is written in Japanese, write the comment in Japanese.
+**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:
 
@@ -168,10 +179,14 @@ After reporting, ask the user:
 
 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
-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
@@ -202,7 +217,21 @@ Example: `fix/12345-page-title-overflow`
   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
 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
 
 - 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(ls *)",
       "WebFetch(domain:github.com)",
-      "mcp__context7__*",
       "mcp__plugin_context7_*",
-      "mcp__github__*",
       "WebSearch",
       "WebFetch"
     ]
@@ -57,8 +55,6 @@
   },
   "enabledPlugins": {
     "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",
         // Debug
         "msjsdiag.debugger-for-chrome",
-        "firefox-devtools.vscode-firefox-debug"
+        "firefox-devtools.vscode-firefox-debug",
+        // prisma
+        "Prisma.prisma@6.19.0"
       ],
       "settings": {
         "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
 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
 turbo run bootstrap
 

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

@@ -20,6 +20,11 @@ pnpm i -g pnpm
 # Install turbo
 pnpm install turbo --global
 
+# Install typescript-language-server for Claude Code LSP plugin
+# Use `npm -g` (not `pnpm --global`) so the binary lands in nvm's node bin, which is on the default PATH.
+# pnpm's global bin requires PNPM_HOME from ~/.bashrc, which the Claude Code extension's shell doesn't source.
+npm install -g typescript-language-server typescript
+
 # Install dependencies
 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 \
           package.json \
           node_modules \
+          tsconfig.base.json \
           apps/app/.next \
           apps/app/config \
           apps/app/dist \
+          apps/app/prisma \
           apps/app/public \
           apps/app/resource \
           apps/app/tmp \
           apps/app/.env.production* \
           apps/app/node_modules \
           apps/app/next.config.js \
-          apps/app/package.json
+          apps/app/package.json \
+          apps/app/tsconfig.json
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
 
     - name: Upload production files as artifact
@@ -124,6 +127,8 @@ jobs:
         - 9200/tcp
         env:
           discovery.type: single-node
+          # ES 9.x enables security (HTTPS + auth) by default; disable for plaintext CI access
+          xpack.security.enabled: false
 
     steps:
     - uses: actions/setup-node@v4
@@ -193,6 +198,8 @@ jobs:
         - 9200/tcp
         env:
           discovery.type: single-node
+          # ES 9.x enables security (HTTPS + auth) by default; disable for plaintext CI access
+          xpack.security.enabled: false
 
     steps:
     - 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"
   },
 
+  "[prisma]": {
+    "editor.defaultFormatter": "Prisma.prisma"
+  },
+
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "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
 
-### 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 |
 |------|-------------|
+| **project-structure** | Monorepo layout, @growi/core role, build order, Changeset workflow |
 | **coding-style** | Coding conventions, naming, exports, immutability, comments |
 | **security** | Security checklist, secret management, OWASP vulnerability prevention |
 | **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 |
 |-------|-------------|
 | **build-error-resolver** | TypeScript/build error resolution with minimal diffs |
 | **security-reviewer** | Security vulnerability detection, OWASP Top 10 |
 
-**Commands** (user-invocable):
+**Commands** (user-invocable via `/`):
 
 | Command | Description |
 |---------|-------------|
 | **/tdd** | Test-driven development workflow |
 | **/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 |
 |-------|-------------|
@@ -63,10 +60,6 @@ Each application has its own CLAUDE.md with detailed instructions:
 - `apps/pdf-converter/CLAUDE.md` - PDF conversion microservice
 - `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
 
 ### Essential Commands (Global)
@@ -94,9 +87,9 @@ growi/
 │   └── slackbot-proxy/     # Slack integration proxy
 ├── packages/               # Shared libraries (@growi/core, @growi/ui, etc.)
 └── .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)
 ```
 
@@ -104,11 +97,13 @@ growi/
 
 1. **Feature-Based Architecture**: Create new features in `features/{feature-name}/`
 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
 
@@ -132,6 +127,6 @@ pnpm run build
 ---
 
 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)

+ 20 - 1
CHANGELOG.md

@@ -1,9 +1,28 @@
 # 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.*
 
+## [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
 
 ### 💎 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
 /.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 SRC_SUBDIR = `${TRANSPILED_DIR}/src`;
 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
 // 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
 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
 ------------------------------------------------
 
-* [`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.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",
-  "version": "7.5.2-RC.0",
+  "version": "7.5.3-RC.0",
   "license": "MIT",
   "private": true,
   "scripts": {
     "//// for production": "",
+    "postinstall": "prisma generate",
     "build": "run-p build:*",
     "start": "next start",
     "build:client": "next build",
@@ -16,16 +17,19 @@
     "preserver": "cross-env NODE_ENV=production pnpm run migrate",
     "pre:styles-commons": "vite build -c vite.vendor-styles-commons.ts",
     "pre:styles-components": "vite build --config vite.vendor-styles-components.ts",
-    "migrate": "node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js",
+    "migrate": "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": "",
     "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-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: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: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": "",
     "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",
@@ -45,6 +49,8 @@
     "//// misc": "",
     "console": "npm run repl",
     "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:generate-spec:apiv3": "sh bin/openapi/generate-spec-apiv3.sh",
     "openapi:generate-spec:apiv1": "sh bin/openapi/generate-spec-apiv1.sh",
@@ -109,11 +115,13 @@
     "@opentelemetry/sdk-node": "^0.202.0",
     "@opentelemetry/sdk-trace-node": "^2.0.1",
     "@opentelemetry/semantic-conventions": "^1.34.0",
+    "@prisma/client": "^6.19.2",
     "@replit/codemirror-emacs": "^6.1.0",
     "@replit/codemirror-vim": "^6.2.1",
     "@replit/codemirror-vscode-keymap": "^6.0.2",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
+    "@swc/helpers": "^0.5.18",
     "@tanstack/react-virtual": "^3.13.12",
     "@types/async": "^3.2.24",
     "@types/multer": "^1.4.12",
@@ -267,9 +275,12 @@
     "swr": "^2.3.2",
     "throttle-debounce": "^5.0.0",
     "ts-deepmerge": "^6.2.0",
+    "ts-node": "^10.9.2",
+    "tsconfig-paths": "^4.2.0",
     "tslib": "^2.8.0",
     "uglifycss": "^0.0.29",
     "uid-safe": "^2.1.5",
+    "umzug": "^3.8.2",
     "unified": "^11.0.0",
     "unist-util-visit": "^5.0.0",
     "unstated": "^2.1.1",
@@ -334,6 +345,7 @@
     "mongodb-connection-string-url": "^7.0.0",
     "mongodb-memory-server-core": "^9.1.1",
     "openapi-typescript": "^7.8.0",
+    "prisma": "^6.19.2",
     "rehype-rewrite": "^4.0.2",
     "remark-github-admonitions-to-directives": "^2.0.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 { useId, useState } from 'react';
+import { useRef, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { Tooltip } from 'reactstrap';
@@ -29,9 +29,7 @@ export const AdminHome: FC = () => {
     }, 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 (
     <div data-testid="admin-home">
@@ -131,7 +129,7 @@ export const AdminHome: FC = () => {
                   onClick={(e) => e.preventDefault()}
                 >
                   <span
-                    id={copyButtonId}
+                    ref={copyIconRef}
                     className="material-symbols-outlined"
                     aria-hidden="true"
                   >
@@ -143,7 +141,7 @@ export const AdminHome: FC = () => {
               <Tooltip
                 placement="bottom"
                 isOpen={copyState === COPY_STATE.DONE}
-                target={copyButtonId}
+                target={copyIconRef}
                 fade={false}
               >
                 {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 (
     <div className="alert alert-warning">
       <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>
     </div>

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

@@ -371,7 +371,7 @@ export const PageItemControlSubstance = (
 
   const {
     data: fetchedPageInfo,
-    error: fetchError,
+    isLoading: isFetchLoading,
     mutate: mutatePageInfo,
   } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
@@ -399,9 +399,8 @@ export const PageItemControlSubstance = (
     [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 =
     !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 <></>;
       return (
         <div className="alert alert-danger">
-          {errors.map((err, index) => {
+          {errors.map((err) => {
             return (
               <small
-                key={`${err.code}-${index}`}
+                key={err.code}
                 // biome-ignore lint/security/noDangerouslySetInnerHtml: rendered HTML from translations
                 dangerouslySetInnerHTML={{
                   __html: tWithOpt(err.message, err.args),
@@ -171,10 +171,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       return (
         <ul className="alert alert-danger">
           {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)}
             </small>
           ))}
@@ -394,8 +391,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
         {registerErrors != null && registerErrors.length > 0 && (
           <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)}
                 <br />
               </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 { UserPicture } from '@growi/ui/dist/components';
 import { Popover, PopoverBody } from 'reactstrap';
@@ -31,7 +31,7 @@ const AvatarWrapper: FC<{
 };
 
 export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
-  const popoverTargetId = useId();
+  const popoverTargetRef = useRef<HTMLButtonElement>(null);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
@@ -56,7 +56,7 @@ export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
           <div className="ms-1">
             <button
               type="button"
-              id={popoverTargetId}
+              ref={popoverTargetRef}
               className="btn border-0 bg-info-subtle rounded-pill p-0"
               onClick={togglePopover}
             >
@@ -67,7 +67,7 @@ export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
             <Popover
               placement="bottom"
               isOpen={isPopoverOpen}
-              target={popoverTargetId}
+              target={popoverTargetRef}
               toggle={togglePopover}
               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 [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [_subordinatedPages, setSubordinatedPages] = useState([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isRenameRecursively, setIsRenameRecursively] = useState(true);
   const [isRenameRedirect, setIsRenameRedirect] = useState(false);
@@ -201,7 +201,7 @@ const PageRenameModalSubstance: React.FC = () => {
     };
 
     return debounce(1000, checkIsPagePathRenameable);
-  }, [isUsersHomepage]);
+  }, []);
 
   useEffect(() => {
     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}
       isEnableActions={isEnableActions}
       isReadOnlyUser={isReadOnlyUser}
+      asLink
       onClick={itemSelectedHandler}
       onWheelClick={itemSelectedByWheelClickHandler}
       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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
 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', () => {
   let container: HTMLDivElement;
   let scrollToTarget: ReturnType<typeof vi.fn>;
@@ -56,7 +72,7 @@ describe('watchRenderingAndReScroll', () => {
     // Trigger a DOM mutation mid-timer
     const child = document.createElement('span');
     container.appendChild(child);
-    await vi.advanceTimersByTimeAsync(0);
+    await flushMutationObservers();
 
     // The timer should NOT have been reset — 2 more seconds should fire it
     vi.advanceTimersByTime(2000);
@@ -65,7 +81,10 @@ describe('watchRenderingAndReScroll', () => {
     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);
 
     vi.advanceTimersByTime(3000);
@@ -76,10 +95,9 @@ describe('watchRenderingAndReScroll', () => {
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     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);
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
 
@@ -154,7 +172,10 @@ describe('watchRenderingAndReScroll', () => {
     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');
     renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl);
@@ -165,13 +186,16 @@ describe('watchRenderingAndReScroll', () => {
     vi.advanceTimersByTime(5000);
     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');
-    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);
-    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+    expect(scrollToTarget).toHaveBeenCalledTimes(2);
 
     cleanup();
   });
@@ -183,12 +207,13 @@ describe('watchRenderingAndReScroll', () => {
 
     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');
 
-    // 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);
     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.
- * 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.
  */
 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,
   );
 
-  const memoizedImportFn = useCallback(importFn, []);
+  const memoizedImportFn = useCallback(importFn, [importFn]);
 
   useEffect(() => {
     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 { apiv3Get } from '~/client/util/apiv3-client';
+import { useIsGuestUser } from '~/states/context';
 
 import type { AccessibleAiAssistantsHasId } from '../../interfaces/ai-assistant';
 
@@ -9,8 +10,10 @@ export const useSWRxAiAssistants = (): SWRResponse<
   AccessibleAiAssistantsHasId,
   Error
 > => {
+  const isGuestUser = useIsGuestUser();
+
   return useSWRImmutable<AccessibleAiAssistantsHasId>(
-    ['/openai/ai-assistants'],
+    !isGuestUser ? ['/openai/ai-assistants'] : null,
     ([endpoint]) =>
       apiv3Get(endpoint).then(
         (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 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
 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 path from 'pathe';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -12,8 +14,10 @@ const moduleClass = styles['simple-item-content'] ?? '';
 
 export const SimpleItemContent = ({
   page,
+  asLink = false,
 }: {
   page: IPageForItem;
+  asLink?: boolean;
 }): JSX.Element => {
   const { t } = useTranslation();
 
@@ -22,41 +26,65 @@ export const SimpleItemContent = ({
   const shouldShowAttentionIcon =
     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 (
     <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 }}
     >
       {shouldShowAttentionIcon && (
         <>
           <span
-            id={spanId}
+            ref={warningIconRef}
             className="material-symbols-outlined mr-2 text-warning"
           >
             warning
           </span>
-          <UncontrolledTooltip placement="top" target={spanId} fade={false}>
+          <UncontrolledTooltip
+            placement="top"
+            target={warningIconRef}
+            fade={false}
+          >
             {t('tooltip.operation.attention.rename')}
           </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>
+        ))}
+      {/* 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>
   );

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

@@ -12,6 +12,7 @@ const indentSize = 10; // in px
 
 type TreeItemLayoutProps = TreeItemProps & {
   className?: string;
+  asLink?: boolean;
 };
 
 export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
@@ -24,6 +25,7 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     isReadOnlyUser,
     isWipPageShown = true,
     showAlternativeContent,
+    asLink,
     onRenamed,
     onClick,
     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">
               {EndComponents?.map((EndComponent, index) => (
                 // 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 StateChangeOptions,
 } from 'downshift';
+import { useAtomValue } from 'jotai';
 import { Modal, ModalBody } from 'reactstrap';
 
+import { useCurrentPagePath } from '~/states/page';
 import { useSetSearchKeyword } from '~/states/search';
+import { isSearchScopeChildrenAsDefaultAtom } from '~/states/server-configurations';
 
 import { isIncludeAiMenthion, removeAiMenthion } from '../../utils/ai';
 import type { DownshiftItem } from '../interfaces/downshift';
@@ -164,17 +167,34 @@ const SearchModal = (): JSX.Element => {
   const { close: closeSearchModal } = useSearchModalActions();
 
   const setSearchKeyword = useSetSearchKeyword();
+  const isSearchScopeChildrenAsDefault = useAtomValue(
+    isSearchScopeChildrenAsDefaultAtom,
+  );
+  const currentPagePath = useCurrentPagePath();
 
   const searchHandler = useCallback(
     (keyword: string) => {
       // invoke override function if exists
       if (onSearchOverride != null) {
         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 (

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

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

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

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

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

@@ -1201,13 +1201,24 @@ module.exports = (crowi: Crowi) => {
       );
 
       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 =
           expandContentWidth === isContainerFluidBySystem
             ? { $unset: { expandContentWidth } } // remove if the specified value is the same to the system's one
             : { $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) {
         logger.error('update-content-width-failed', err);
         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;
 
       try {
-        const page = await Page.findById(pageId);
+        const page = await Page.findByIdAndViewer(pageId, req.user);
         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();

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

@@ -47,9 +47,15 @@ export const unpublishPageHandlersFactory = (
       const { pageId } = req.params;
 
       try {
-        const page = await Page.findById(pageId);
+        const page = await Page.findByIdAndViewer(pageId, req.user);
         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();

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

@@ -111,9 +111,7 @@ module.exports = (crowi) => {
 
   validator.getShareLinks = [
     // 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 = [
     // 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.
     body('expiredAt')
       .if((value) => value != null)
@@ -268,9 +264,7 @@ module.exports = (crowi) => {
 
   validator.deleteShareLinks = [
     // 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) {
-    const proxyUri = slackIntegrationService.proxyUriForCurrentType ?? null; // can be null
+    const proxyUri = slackIntegrationService.proxyUriForCurrentType;
 
     const appSiteUrl = growiInfoService.getSiteUrl();
     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',
     defaultValue: undefined,
   }),
-  'slackbot:proxyUri': defineConfig<string | undefined>({
+  'slackbot:proxyUri': defineConfig<NonBlankString | undefined>({
     envVarName: 'SLACKBOT_INTEGRATION_PROXY_URI',
     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 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;
 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 {
   type GrowiBotEvent,
   type GrowiCommand,
@@ -25,7 +29,8 @@ import { LinkSharedEventHandler } from './slack-event-handler/link-shared';
 
 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 };
 
@@ -127,23 +132,19 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     return true;
   }
 
-  get proxyUriForCurrentType(): string | undefined {
+  get proxyUriForCurrentType(): NonBlankString | undefined {
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
 
     // TODO assert currentBotType is not null and CUSTOM_WITHOUT_PROXY
 
-    let proxyUri: string | undefined;
-
     switch (currentBotType) {
       case SlackbotType.OFFICIAL:
-        proxyUri = OFFICIAL_SLACKBOT_PROXY_URI;
-        break;
+        return OFFICIAL_SLACKBOT_PROXY_URI;
       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-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
       it('returns false when safe and unsafe headers are mixed', () => {
         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 path from 'pathe';
+
 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 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>>;
 

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

@@ -66,10 +66,10 @@ export const useCurrentProductNavWidth = () => {
 
 // Export base atoms for SSR hydration
 export {
-  preferCollapsedModeAtom,
-  isCollapsedContentsOpenedAtom,
-  currentSidebarContentsAtom,
   currentProductNavWidthAtom,
+  currentSidebarContentsAtom,
+  isCollapsedContentsOpenedAtom,
+  preferCollapsedModeAtom,
 };
 
 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');
 };
 
+const hasShareLinkId = (
+  shareLinkId: string | null | undefined,
+): shareLinkId is string => {
+  return shareLinkId != null && shareLinkId.trim().length > 0;
+};
+
 /**
  * Build query params for /page/info endpoint.
  * Only includes shareLinkId when it is a non-empty string.
@@ -98,7 +104,7 @@ const buildPageInfoParams = (
   pageId: string,
   shareLinkId: string | null | undefined,
 ): { pageId: string; shareLinkId?: string } => {
-  if (shareLinkId != null && shareLinkId.trim().length > 0) {
+  if (hasShareLinkId(shareLinkId)) {
     return { pageId, shareLinkId };
   }
   return { pageId };
@@ -113,9 +119,10 @@ export const useSWRxPageInfo = (
   const isGuestUser = useIsGuestUser();
 
   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]);
 
   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 type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import { useIsGuestUser } from '~/states/context';
 import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user';
 
 export const useSWRxUsersList = (
   userIds: string[],
 ): SWRResponse<IUserHasId[], Error> => {
+  const isGuestUser = useIsGuestUser();
   const distinctUserIds =
     userIds.length > 0 ? Array.from(new Set(userIds)).sort() : [];
+
+  const shouldFetch = !isGuestUser && distinctUserIds.length > 0;
+
   return useSWR(
-    distinctUserIds.length > 0 ? ['/users/list', distinctUserIds] : null,
+    shouldFetch ? ['/users/list', distinctUserIds] : null,
     ([endpoint, userIds]) =>
       apiv3Get(endpoint, { userIds: userIds.join(',') }).then((response) => {
         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 }
     ]
   },
-  "include": ["next-env.d.ts", "config", "src"],
+  "include": ["next-env.d.ts", "config", "prisma", "src"],
   "exclude": ["src/**/*.vendor-styles.*"],
   "ts-node": {
     "transpileOnly": true,

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.5.2-slackbot-proxy.0",
+  "version": "7.5.3-slackbot-proxy.0",
   "license": "MIT",
   "private": true,
   "scripts": {

+ 7 - 14
biome.json

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

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.5.2-RC.0",
+  "version": "7.5.3-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": true,
@@ -41,7 +41,7 @@
   },
   "// comments for devDependencies": {},
   "devDependencies": {
-    "@biomejs/biome": "^2.2.6",
+    "@biomejs/biome": "^2.4.12",
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/cli": "^2.27.3",
     "@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> => {
   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;
 

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

@@ -4,16 +4,16 @@ import { isValidObjectId } from './objectid-utils';
 
 describe('isValidObjectId', () => {
   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 }) => {
-    test(`when the argument is '${arg}'`, async () => {
+    test(`when the argument is '${arg}'`, () => {
       // when:
       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);
     });
 
-    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);
   });
 
-  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', () => {
@@ -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', () => {

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

@@ -8,4 +8,5 @@ export {
   TextGrowiPluginDirectiveData,
 } from './mdast-util-growi-directive';
 
+// biome-ignore lint/style/noDefaultExport: remark plugins are conventionally consumed as default imports
 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 {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(
   effects,
   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 {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(
   effects,
   ok,

Разница между файлами не показана из-за своего большого размера
+ 350 - 67
pnpm-lock.yaml


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