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

Merge pull request #11019 from growilabs/master

Release v7.5.2
mergify[bot] 1 день назад
Родитель
Сommit
f4cf1c7d4d
57 измененных файлов с 858 добавлено и 1071 удалено
  1. 53 12
      .claude/commands/invest-issue.md
  2. 70 150
      .claude/rules/coding-style.md
  3. 46 0
      .claude/rules/github-cli.md
  4. 66 0
      .claude/rules/lsp.md
  5. 87 0
      .claude/rules/project-structure.md
  6. 1 5
      .claude/settings.json
  7. 0 252
      .claude/skills/monorepo-overview/SKILL.md
  8. 0 269
      .claude/skills/tech-stack/SKILL.md
  9. 5 0
      .devcontainer/app/postCreateCommand.sh
  10. 5 0
      .devcontainer/pdf-converter/postCreateCommand.sh
  11. 0 65
      .serena/memories/page-transition-and-rendering-flow.md
  12. 0 23
      .vscode/mcp.json
  13. 23 28
      AGENTS.md
  14. 23 0
      apps/app/.claude/rules/ui-pitfalls.md
  15. 138 0
      apps/app/.claude/skills/learned/page-transition-and-rendering-flow/SKILL.md
  16. 1 1
      apps/app/package.json
  17. 4 6
      apps/app/src/client/components/Admin/AdminHome/AdminHome.tsx
  18. 2 2
      apps/app/src/client/components/Admin/G2GDataTransferExportForm.tsx
  19. 3 4
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  20. 5 8
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  21. 4 4
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx
  22. 2 2
      apps/app/src/client/components/PageRenameModal/PageRenameModal.tsx
  23. 1 0
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  24. 39 14
      apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx
  25. 3 1
      apps/app/src/client/util/watch-rendering-and-rescroll.ts
  26. 1 1
      apps/app/src/components/utils/use-lazy-loader.ts
  27. 4 1
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  28. 8 7
      apps/app/src/features/openai/server/services/client-delegator/get-client.ts
  29. 47 19
      apps/app/src/features/page-tree/components/SimpleItemContent.tsx
  30. 3 1
      apps/app/src/features/page-tree/components/TreeItemLayout.tsx
  31. 23 3
      apps/app/src/features/search/client/components/SearchModal.tsx
  32. 1 4
      apps/app/src/pages/admin/_shared/layout.tsx
  33. 1 3
      apps/app/src/server/routes/apiv3/page-listing.ts
  34. 3 9
      apps/app/src/server/routes/apiv3/share-links.js
  35. 1 1
      apps/app/src/server/routes/apiv3/slack-integration.js
  36. 1 1
      apps/app/src/server/service/config-manager/config-definition.ts
  37. 16 13
      apps/app/src/server/service/file-uploader/multipart-uploader.spec.ts
  38. 10 9
      apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts
  39. 11 10
      apps/app/src/server/service/slack-integration.ts
  40. 9 10
      apps/app/src/server/util/is-simple-request.spec.ts
  41. 1 0
      apps/app/src/server/util/safe-path-utils.ts
  42. 2 3
      apps/app/src/services/renderer/recommended-whitelist.ts
  43. 21 1
      apps/app/src/states/global/hydrate.ts
  44. 3 3
      apps/app/src/states/ui/sidebar/sidebar.ts
  45. 11 4
      apps/app/src/stores/page.tsx
  46. 6 1
      apps/app/src/stores/user.tsx
  47. 1 1
      apps/slackbot-proxy/package.json
  48. 6 14
      biome.json
  49. 2 2
      package.json
  50. 1 8
      packages/core/src/models/serializers/user-serializer.ts
  51. 9 9
      packages/core/src/utils/objectid-utils.spec.ts
  52. 6 7
      packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts
  53. 29 41
      packages/core/src/utils/page-path-utils/index.spec.ts
  54. 1 0
      packages/remark-growi-directive/src/index.js
  55. 1 1
      packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/factory-attributes.js
  56. 1 1
      packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/factory-label.js
  57. 37 37
      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

+ 70 - 150
.claude/rules/coding-style.md

@@ -1,6 +1,6 @@
 # Coding Style
 
-General coding standards and best practices. These rules apply to all code in the GROWI monorepo.
+General coding standards for all code in the GROWI monorepo.
 
 ## Immutability (CRITICAL)
 
@@ -8,32 +8,20 @@ ALWAYS create new objects, NEVER mutate:
 
 ```typescript
 // ❌ WRONG: Mutation
-function updateUser(user, name) {
-  user.name = name  // MUTATION!
-  return user
-}
-
-// ✅ CORRECT: Immutability
-function updateUser(user, name) {
-  return {
-    ...user,
-    name
-  }
-}
-
-// ✅ CORRECT: Array immutable update
-const updatedPages = pages.map(p => p.id === id ? { ...p, title: newTitle } : p);
-
-// ❌ WRONG: Array mutation
+user.name = name;
 pages[index].title = newTitle;
+
+// ✅ CORRECT: Immutable update
+return { ...user, name };
+const updatedPages = pages.map(p => (p.id === id ? { ...p, title: newTitle } : p));
 ```
 
 ## File Organization
 
 MANY SMALL FILES > FEW LARGE FILES:
 
-- High cohesion, low coupling
-- 200-400 lines typical, 800 max
+- **High cohesion, low coupling** — every symbol in a file should share a single responsibility; if you struggle to name the file, it probably has too many
+- 200–400 lines typical, 800 max
 - Functions < 50 lines
 - Extract utilities from large components
 - Organize by feature/domain, not by type
@@ -42,20 +30,13 @@ MANY SMALL FILES > FEW LARGE FILES:
 
 ### Pure Function Extraction
 
-When a framework-specific wrapper (React hook, Express middleware, CodeMirror extension handler, etc.) contains non-trivial logic, extract the core logic as a **pure function** and reduce the wrapper to a thin adapter. This enables direct reuse across different contexts and makes unit testing straightforward.
+When a framework-specific wrapper (React hook, Express middleware, CodeMirror extension handler, etc.) contains non-trivial logic, extract the core logic as a **pure function** and reduce the wrapper to a thin adapter. This enables reuse across contexts and makes unit testing straightforward.
 
 ```typescript
-// ❌ WRONG: Business logic locked inside a framework-specific wrapper
-export const useToggleSymbol = (view?: EditorView) => {
-  return useCallback((prefix, suffix) => {
-    // 30 lines of symbol-toggling logic here...
-  }, [view]);
-};
-
-// ✅ CORRECT: Pure function + thin wrappers for each context
+// ✅ Pure function — testable, reusable from hooks, keymaps, shortcuts, etc.
 // services-internal/markdown-utils/toggle-markdown-symbol.ts
 export const toggleMarkdownSymbol = (view: EditorView, prefix: string, suffix: string): void => {
-  // Pure logic — testable, reusable from hooks, keymaps, shortcuts, etc.
+  // Pure logic
 };
 
 // React hook wrapper
@@ -83,7 +64,7 @@ Replace conditional branching on mode/variant names with **declared metadata** t
 ```typescript
 // ❌ WRONG: Consumer knows mode-specific behavior
 if (keymapModeName === 'emacs') {
-  return sharedKeyBindings; // exclude formatting
+  return sharedKeyBindings;
 }
 return [formattingBindings, ...sharedKeyBindings];
 
@@ -96,7 +77,7 @@ const activeBindings = allGroups
 
 ### Factory Pattern with Encapsulated Metadata
 
-When a module produces a value that requires configuration from the consumer (precedence, feature flags, etc.), **bundle the metadata alongside the value** in a structured return type. This keeps decision-making inside the module that has the knowledge.
+When a module produces a value that needs consumer-side configuration (precedence, feature flags, etc.), **bundle the metadata alongside the value** in a structured return type. This keeps decision-making inside the module that has the knowledge.
 
 ```typescript
 // ❌ WRONG: Consumer decides precedence based on mode name
@@ -115,7 +96,7 @@ codeMirrorEditor.appendExtensions(result.precedence(result.extension));
 
 ### Responsibility-Based Submodule Decomposition
 
-When a single module grows beyond ~200 lines or accumulates multiple distinct responsibilities, split into submodules **by responsibility domain** (not by arbitrary size). Each submodule should be independently understandable.
+When a module grows beyond ~200 lines or accumulates multiple distinct responsibilities, split into submodules **by responsibility domain** (not by arbitrary size). Each submodule should be independently understandable.
 
 ```
 // ❌ WRONG: One large file with mixed concerns
@@ -129,82 +110,77 @@ keymaps/emacs/
 └── navigation.ts     ← Movement and editing commands
 ```
 
-## Naming Conventions
+## Module Public Surface: Barrel Files & Directory Boundaries
 
-### Variables and Functions
+Treat a directory as a **module with a single public entry point** (`index.ts`). The barrel declares the public API; everything else is an implementation detail.
 
-- **camelCase** for variables and functions
-- **PascalCase** for classes, interfaces, types, React components
-- **UPPER_SNAKE_CASE** for constants
+### Rules
 
-```typescript
-const pageId = '123';
-const MAX_PAGE_SIZE = 1000;
+1. **One barrel per module directory.** `index.ts` is the sole export surface. Siblings/parents import only from the barrel, never reach into internal files.
+2. **Re-export only what callers need.** If a symbol has no external caller, do not add it to the barrel. Expanding the surface is a deliberate decision, not a default.
+3. **Nest subdirectories for cohesive internals.** When a module has a cluster of related files (DOM rendering, parsers, adapters), move them into a subdirectory with its own barrel. The parent barrel imports from that subdirectory without leaking its internals.
+4. **Prefer `import { X } from './dom'` over `import { X } from './dom/widget'`.** Reaching through a barrel keeps the coupling at the module level, not the file level.
 
-function getPageById(id: string) { }
-class PageService { }
-interface PageData { }
-type PageStatus = 'draft' | 'published';
+### Example (from `y-rich-cursors/` refactor)
+
+```
+y-rich-cursors/
+├── index.ts                      ← Public API: only yRichCursors() + YRichCursorsOptions
+├── plugin.ts                     ← Internal orchestrator
+├── activity-tracker.ts           ← Internal
+├── local-cursor.ts               ← Internal
+├── viewport-classification.ts    ← Internal
+└── dom/
+    ├── index.ts                  ← Sub-barrel: exposes widget/theme/indicator to siblings only
+    ├── widget.ts
+    ├── theme.ts
+    └── off-screen-indicator.ts
 ```
 
-### Files and Directories
+Before the refactor, `index.ts` re-exported `RichCaretWidget`, `createOffScreenIndicator`, `ScrollCallbackRef`, etc. — internal details leaked as public API. After: the top-level barrel exposes the single `yRichCursors` entry point, and DOM concerns live behind `dom/index.ts` so that sibling modules (`plugin.ts`) can consume them without making them part of the module's external contract.
+
+**When adding a new file, ask**: is this intended for external callers? If no, it does not belong in the top-level barrel. If it is one of several related internals, consider a subdirectory.
 
-- **PascalCase** for React components: `Button.tsx`, `PageTree.tsx`
-- **kebab-case** for utilities: `page-utils.ts`
-- **lowercase** for directories: `features/page-tree/`, `utils/`
+## Naming Conventions
+
+- **camelCase** — variables, functions
+- **PascalCase** — classes, interfaces, types, React components
+- **UPPER_SNAKE_CASE** — constants
+- **PascalCase** file — React components (`Button.tsx`)
+- **kebab-case** file — utilities (`page-utils.ts`)
+- **lowercase** directory — `features/page-tree/`, `utils/`
 
 ## Export Style
 
 **Prefer named exports** over default exports:
 
 ```typescript
-// ✅ Good: Named exports
+// ✅ Good
 export const MyComponent = () => { };
-export function myFunction() { }
-export class MyClass { }
 
-// ❌ Avoid: Default exports
+// ❌ Avoid (exception: Next.js pages)
 export default MyComponent;
 ```
 
-**Why?**
-- Better refactoring (IDEs can reliably rename across files)
-- Better tree shaking
-- Explicit imports improve readability
-- No ambiguity (import name matches export name)
-
-**Exception**: Next.js pages require default exports.
+Named exports give reliable IDE rename, better tree shaking, and unambiguous import names.
 
 ## Type Safety
 
-**Always provide explicit types** for function parameters and return values:
+Always provide explicit types for function parameters and return values. Use `import type` for type-only imports.
 
 ```typescript
-// ✅ Good: Explicit types
-function createPage(path: string, body: string): Promise<Page> {
-  // ...
-}
-
-// ❌ Avoid: Implicit any
-function createPage(path, body) {
-  // ...
-}
-```
-
-Use `import type` for type-only imports:
+function createPage(path: string, body: string): Promise<Page> { /* ... */ }
 
-```typescript
 import type { PageData } from '~/interfaces/page';
 ```
 
 ## Error Handling
 
-ALWAYS handle errors comprehensively:
+Handle errors comprehensively — log with context, rethrow with a user-friendly message:
 
 ```typescript
 try {
-  const result = await riskyOperation();
-  return result;
+  return await riskyOperation();
 } catch (error) {
   logger.error('Operation failed:', { error, context });
   throw new Error('Detailed user-friendly message');
@@ -213,68 +189,23 @@ try {
 
 ## Async/Await
 
-Prefer async/await over Promise chains:
-
-```typescript
-// ✅ Good: async/await
-async function loadPages() {
-  const pages = await fetchPages();
-  const enriched = await enrichPageData(pages);
-  return enriched;
-}
-
-// ❌ Avoid: Promise chains
-function loadPages() {
-  return fetchPages()
-    .then(pages => enrichPageData(pages))
-    .then(enriched => enriched);
-}
-```
+Prefer `async/await` over `.then()` chains.
 
 ## Comments
 
-**Write comments in English** (even for Japanese developers):
-
-```typescript
-// ✅ Good: English comment
-// Calculate the total number of pages in the workspace
-
-// ❌ Avoid: Japanese comment
-// ワークスペース内のページ総数を計算
-```
-
-**When to comment**:
-- Complex algorithms or business logic
-- Non-obvious workarounds
-- Public APIs and interfaces
-
-**When NOT to comment**:
-- Self-explanatory code (good naming is better)
-- Restating what the code does
+**Write comments in English.** Only comment when the WHY is non-obvious (hidden constraints, invariants, workarounds). Do not restate what the code does — let naming do that work.
 
 ## Test File Placement
 
-**Co-locate tests with source files** in the same directory:
-
-```
-src/utils/
-├── helper.ts
-└── helper.spec.ts        # Test next to source
-
-src/components/Button/
-├── Button.tsx
-└── Button.spec.tsx       # Test next to component
-```
-
-### Test File Naming
+Co-locate tests with source files.
 
-- Unit tests: `*.spec.{ts,js}`
-- Integration tests: `*.integ.ts`
-- Component tests: `*.spec.{tsx,jsx}`
+- Unit: `*.spec.{ts,js}`
+- Integration: `*.integ.ts`
+- Component: `*.spec.{tsx,jsx}`
 
 ## Git Commit Messages
 
-Follow conventional commit format:
+Conventional commits:
 
 ```
 <type>(<scope>): <subject>
@@ -282,33 +213,20 @@ Follow conventional commit format:
 <body>
 ```
 
-**Types**: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`
-
-**Example**:
-```
-feat(page-tree): add virtualization for large trees
-
-Implemented react-window for virtualizing page tree
-to improve performance with 10k+ pages.
-```
+Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`.
 
 ## Cross-Platform Compatibility
 
 GROWI must work on Windows, macOS, and Linux. Never use platform-specific shell commands in npm scripts.
 
 ```json
-// ❌ WRONG: Unix-only commands in npm scripts
-"clean": "rm -rf dist",
-"copy": "cp src/foo.ts dist/foo.ts",
-"move": "mv src dist"
-
-// ✅ CORRECT: Cross-platform tools
-"clean": "rimraf dist",
-"copy": "node -e \"require('fs').cpSync('src/foo.ts','dist/foo.ts')\"",
-"move": "node -e \"require('fs').renameSync('src','dist')\""
+// ❌ WRONG: Unix-only
+"clean": "rm -rf dist"
+
+// ✅ CORRECT: Cross-platform
+"clean": "rimraf dist"
 ```
 
-**Rules**:
 - Use `rimraf` instead of `rm -rf`
 - Use Node.js one-liners or cross-platform tools (`cpy-cli`, `cpx2`) instead of `cp`, `mv`, `echo`, `ls`
 - Never assume a POSIX shell in npm scripts
@@ -319,10 +237,10 @@ Before marking work complete:
 
 - [ ] Code is readable and well-named
 - [ ] Functions are small (<50 lines)
-- [ ] Files are focused (<800 lines)
+- [ ] Files are focused (<800 lines) and **highly cohesive** (one responsibility per file; filename describes all symbols)
 - [ ] No deep nesting (>4 levels)
 - [ ] Proper error handling
-- [ ] No console.log statements (use logger)
+- [ ] No `console.log` (use logger)
 - [ ] No mutation (immutable patterns used)
 - [ ] Named exports (except Next.js pages)
 - [ ] English comments
@@ -330,3 +248,5 @@ Before marking work complete:
 - [ ] Non-trivial logic extracted as pure functions from framework wrappers
 - [ ] No hard-coded mode/variant checks in consumers (use declared metadata)
 - [ ] Modules with multiple responsibilities split by domain
+- [ ] **Module public surface is minimal** — `index.ts` re-exports only what external callers need; internals stay unexported
+- [ ] **Cohesive internals are grouped in subdirectories** with their own barrel, not flattened into the parent

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

+ 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
 

+ 0 - 65
.serena/memories/page-transition-and-rendering-flow.md

@@ -1,65 +0,0 @@
-# ページ遷移とレンダリングのデータフロー
-
-このドキュメントは、GROWIのページ遷移からレンダリングまでのデータフローを解説します。
-
-## 登場人物
-
-1.  **`[[...path]].page.tsx`**: Next.js の動的ルーティングを担うメインコンポーネント。サーバーサイドとクライアントサイドの両方で動作します。
-2.  **`useSameRouteNavigation.ts`**: クライアントサイドでのパス変更を検知し、データ取得を**トリガー**するフック。
-3.  **`useFetchCurrentPage.ts`**: データ取得と関連する Jotai atom の更新を一元管理するフック。データ取得が本当に必要かどうかの最終判断も担います。
-4.  **`useShallowRouting.ts`**: サーバーサイドで正規化されたパスとブラウザのURLを同期させるフック。
-5.  **`server-side-props.ts`**: サーバーサイドレンダリング(SSR)時にページデータを取得し、`props` としてページコンポーネントに渡します。
-
----
-
-## フロー1: サーバーサイドレンダリング(初回アクセス時)
-
-ユーザーがURLに直接アクセスするか、ページをリロードした際のフローです。
-
-1.  **リクエスト受信**: サーバーがユーザーからのリクエスト(例: `/user/username/memo`)を受け取ります。
-2.  **`getServerSideProps` の実行**:
-    - `server-side-props.ts` の `getServerSidePropsForInitial` が実行されます。
-    - `retrievePageData` が呼び出され、パスの正規化(例: `/user/username` → `/user/username/`)が行われ、APIからページデータを取得します。
-    - 取得したデータと、正規化後のパス (`currentPathname`) を `props` として `[[...path]].page.tsx` に渡します。
-3.  **コンポーネントのレンダリングとJotai Atomの初期化**:
-    - `[[...path]].page.tsx` は `props` を受け取り、そのデータで `currentPageDataAtom` などのJotai Atomを初期化します。
-    - `PageView` などのコンポーネントがサーバーサイドでレンダリングされます。
-4.  **クライアントサイドでのハイドレーションとURL正規化**:
-    - レンダリングされたHTMLがブラウザに送信され、Reactがハイドレーションを行います。
-    - **`useShallowRouting`** が実行され、ブラウザのURL (`/user/username/memo`) と `props.currentPathname` (`/user/username/memo/`) を比較します。
-    - 差異がある場合、`router.replace` を `shallow: true` で実行し、ブラウザのURLをサーバーが認識している正規化後のパスに静かに更新します。
-
----
-
-## フロー2: クライアントサイドナビゲーション(`<Link>` クリック時)
-
-アプリケーション内でページ間を移動する際のフローです。
-
-1.  **ナビゲーション開始**:
-    - ユーザーが `<Link href="/new/page">` をクリックします。
-    - Next.js の `useRouter` がURLの変更を検出し、`[[...path]].page.tsx` が再評価されます。
-2.  **`useSameRouteNavigation` によるトリガー**:
-    - このフックの `useEffect` が `router.asPath` の変更 (`/new/page`) を検知します。
-    - **`fetchCurrentPage({ path: '/new/page' })`** を呼び出します。このフックは常にデータ取得を試みます。
-3.  **`useFetchCurrentPage` によるデータ取得の判断と実行**:
-    - `fetchCurrentPage` 関数が実行されます。
-    - **3a. パスの前処理**:
-        - まず、引数で渡された `path` をデコードします(例: `encoded%2Fpath` → `encoded/path`)。
-        - 次に、パスがパーマリンク形式(例: `/65d4e0a0f7b7b2e5a8652e86`)かどうかを判定します。
-    - **3b. 重複取得の防止(ガード節)**:
-        - 前処理したパスや、パーマリンクから抽出したページIDが、現在Jotaiで管理されているページのパスやIDと同じでないかチェックします。
-        - 同じであれば、APIを叩かずに処理を中断し、現在のページデータを返します。
-    - **3c. 読み込み状態開始**: `pageLoadingAtom` を `true` に設定します。
-    - **3d. API通信**: `apiv3Get('/page', ...)` を実行してサーバーから新しいページデータを取得します。パラメータには、パス、ページID、リビジョンIDなどが含まれます。
-4.  **アトミックな状態更新**:
-    - **API成功時**:
-        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageEntityIdAtom`, `currentPageEmptyIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
-        - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。
-    - **APIエラー時 (例: 404 Not Found)**:
-        - `pageErrorAtom` にエラーオブジェクトを設定します。
-        - `pageNotFoundAtom` を `true` に設定します。
-        - 最後に `pageLoadingAtom` を `false` に設定します。
-5.  **`PageView` の最終レンダリング**:
-    - `currentPageDataAtom` の更新がトリガーとなり、`PageView` コンポーネントが新しいデータで再レンダリングされます。
-6.  **副作用の実行**:
-    - `useSameRouteNavigation` 内で `fetchCurrentPage` が完了した後、`mutateEditingMarkdown` が呼び出され、エディタの状態が更新されます。

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

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

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

+ 138 - 0
apps/app/.claude/skills/learned/page-transition-and-rendering-flow/SKILL.md

@@ -0,0 +1,138 @@
+---
+name: page-transition-and-rendering-flow
+description: Auto-invoked when modifying page transition logic, global atom hydration, or the `[[...path]]` dynamic route. Explains the data flow from SSR/client navigation to page rendering, and the hydration-vs-subsequent-sync rule for global atoms (`currentPathnameAtom`, `currentUserAtom`, `isMaintenanceModeAtom`).
+---
+
+# Page Transition and Rendering Flow
+
+## Problem
+
+The page transition path in GROWI spans SSR, client-side navigation, URL normalization, Jotai atom hydration, and asynchronous data fetching. Changes in any one of these layers can cause subtle regressions in other layers:
+
+- Forcing global atom updates during render causes "setState during render of a different component" warnings.
+- Moving hydration into `useEffect` without care causes flashes of stale values or hydration mismatches.
+- Confusing `router.asPath` vs `props.currentPathname` vs `currentPathnameAtom` leads to inconsistent reads across the transition.
+
+This skill documents the intended flow so that edits preserve invariants.
+
+## Key Actors
+
+1. **`pages/[[...path]]/index.page.tsx`** — Dynamic route component. Runs on server and client. Hydrates page-data atoms (`currentPageDataAtom`, etc.) from `getServerSideProps` output.
+2. **`pages/[[...path]]/use-same-route-navigation.ts`** — Detects `router.asPath` changes on the client and *triggers* `fetchCurrentPage`. Always attempts fetch; actual skip is decided inside `useFetchCurrentPage`.
+3. **`states/page/use-fetch-current-page.ts`** — Single source of truth for page data fetching. Decides whether a fetch is actually needed (guards against duplicate fetches by comparing decoded path / permalink ID with current atom state). Updates page-data atoms atomically on success to avoid intermediate states.
+4. **`pages/[[...path]]/use-shallow-routing.ts`** — After hydration, compares the browser URL with `props.currentPathname` (the server-normalized path) and issues a `router.replace(..., { shallow: true })` to align them.
+5. **`pages/[[...path]]/server-side-props.ts`** — `getServerSidePropsForInitial` calls `retrievePageData`, performs path normalization (e.g. `/user/username` → `/user/username/`), and returns data + normalized `currentPathname` as props.
+6. **`pages/_app.page.tsx` / `states/global/hydrate.ts`** — Hydrates global atoms (`currentPathnameAtom`, `currentUserAtom`, `isMaintenanceModeAtom`) via `useHydrateGlobalEachAtoms`.
+
+## Flow 1: Server-Side Rendering (first load / reload)
+
+1. **Request received**: server receives request (e.g. `/user/username/memo`).
+2. **`getServerSideProps` runs**:
+   - `getServerSidePropsForInitial` executes.
+   - `retrievePageData` normalizes the path and fetches page data from the API.
+   - Returns page data and normalized `currentPathname` as props.
+3. **Component renders, atoms initialized**:
+   - `[[...path]]/index.page.tsx` receives props and initializes page-data atoms (`currentPageDataAtom`, etc.).
+   - `PageView` and children render on the server.
+4. **Client-side hydration + URL alignment**:
+   - Browser receives HTML; React hydrates.
+   - `useShallowRouting` compares browser URL (`/user/username/memo`) against `props.currentPathname` (`/user/username/memo/`).
+   - On mismatch, `router.replace(..., { shallow: true })` silently rewrites the browser URL to the server-normalized path.
+
+## Flow 2: Client-Side Navigation (`<Link>` click)
+
+1. **Navigation start**: user clicks `<Link href="/new/page">`. `useRouter` detects URL change and `[[...path]]/index.page.tsx` re-evaluates.
+2. **`useSameRouteNavigation` triggers fetch**:
+   - Its `useEffect` detects `router.asPath` change (`/new/page`).
+   - Calls `fetchCurrentPage({ path: '/new/page' })`. This hook always attempts the call.
+3. **`useFetchCurrentPage` decides and executes**:
+   - **3a. Path preprocessing**: decodes the path; detects permalink format (e.g. `/65d4...`).
+   - **3b. Dedup guard**: compares preprocessed path / extracted page ID against current Jotai state. If equal, returns without hitting the API.
+   - **3c. Loading flag**: sets `pageLoadingAtom = true`.
+   - **3d. API call**: `apiv3Get('/page', ...)` with path / pageId / revisionId.
+4. **Atomic state update**:
+   - **Success**: all relevant atoms (`currentPageDataAtom`, `currentPageEntityIdAtom`, `currentPageEmptyIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom`, …) are updated together, avoiding intermediate states where `pageId` is temporarily undefined.
+   - **Error (e.g. 404)**: `pageErrorAtom` set, `pageNotFoundAtom = true`, `pageLoadingAtom = false` last.
+5. **`PageView` re-renders** with the new data.
+6. **Side effects**: after `fetchCurrentPage` completes, `useSameRouteNavigation` calls `mutateEditingMarkdown` to refresh editor state.
+
+## Critical Rule: Global Atom Hydration vs Subsequent Sync
+
+**Rule**: In `useHydrateGlobalEachAtoms` (and similar hooks that run inside `_app.page.tsx`), **do not** use `useHydrateAtoms(tuples, { dangerouslyForceHydrate: true })` to keep atoms aligned with `commonEachProps` across navigations.
+
+### Why
+
+- `useHydrateAtoms` runs during render. With `dangerouslyForceHydrate: true`, it re-writes atom values on *every* render — including navigations when props change.
+- Those atoms are subscribed by already-mounted components (e.g. `PageViewComponent`). Writing to them mid-render triggers setState on sibling components during the parent's render, producing:
+  > Warning: Cannot update a component (`PageViewComponent`) while rendering a different component (`GrowiAppSubstance`).
+
+### Correct pattern
+
+Split the two concerns:
+
+```ts
+export const useHydrateGlobalEachAtoms = (commonEachProps: CommonEachProps): void => {
+  // 1. Initial hydration only — so children read correct values on first render
+  const tuples = [
+    createAtomTuple(currentPathnameAtom, commonEachProps.currentPathname),
+    createAtomTuple(currentUserAtom, commonEachProps.currentUser),
+    createAtomTuple(isMaintenanceModeAtom, commonEachProps.isMaintenanceMode),
+  ];
+  useHydrateAtoms(tuples); // force NOT enabled
+
+  // 2. Subsequent sync (route transitions) — run after commit to avoid render-time setState
+  const setCurrentPathname = useSetAtom(currentPathnameAtom);
+  const setCurrentUser = useSetAtom(currentUserAtom);
+  const setIsMaintenanceMode = useSetAtom(isMaintenanceModeAtom);
+
+  useEffect(() => {
+    setCurrentPathname(commonEachProps.currentPathname);
+  }, [commonEachProps.currentPathname, setCurrentPathname]);
+
+  useEffect(() => {
+    setCurrentUser(commonEachProps.currentUser);
+  }, [commonEachProps.currentUser, setCurrentUser]);
+
+  useEffect(() => {
+    setIsMaintenanceMode(commonEachProps.isMaintenanceMode);
+  }, [commonEachProps.isMaintenanceMode, setIsMaintenanceMode]);
+};
+```
+
+### Trade-off accepted by this pattern
+
+On a route transition, `currentPathnameAtom` is **one render behind** before the effect commits. This is safe because:
+
+- Data fetching (`useSameRouteNavigation`, `useFetchCurrentPage`, `useShallowRouting`) reads `router.asPath` or `props.currentPathname` directly — not `currentPathnameAtom`.
+- `useCurrentPagePath` uses `currentPagePathAtom` (page data) as primary and falls back to `currentPathname` only when the page data is absent.
+- Jotai's `Object.is` comparison means the effect is a no-op when the value hasn't actually changed, so setters don't need manual guards.
+
+## Source Reference Map
+
+| Concern | File |
+|---|---|
+| Dynamic route entry | `apps/app/src/pages/[[...path]]/index.page.tsx` |
+| SSR props | `apps/app/src/pages/[[...path]]/server-side-props.ts` |
+| Route-change trigger | `apps/app/src/pages/[[...path]]/use-same-route-navigation.ts` |
+| URL normalization | `apps/app/src/pages/[[...path]]/use-shallow-routing.ts` |
+| Page fetch / atom updates | `apps/app/src/states/page/use-fetch-current-page.ts` |
+| Page path selector | `apps/app/src/states/page/hooks.ts` (`useCurrentPagePath`) |
+| Global atom hydration | `apps/app/src/states/global/hydrate.ts` |
+| Global atom definitions | `apps/app/src/states/global/global.ts` |
+| App shell | `apps/app/src/pages/_app.page.tsx` (`GrowiAppSubstance`) |
+
+## When to Apply
+
+- Editing any hook under `states/global/` that hydrates from `commonEachProps` / `commonInitialProps`.
+- Modifying `useSameRouteNavigation`, `useFetchCurrentPage`, or `useShallowRouting`.
+- Adding new global atoms that must stay aligned with server-side props across navigations.
+- Touching `_app.page.tsx` render order or provider composition.
+- Debugging "setState during render of a different component" warnings originating from `_app.page.tsx` or `GrowiAppSubstance`.
+
+## Common Pitfalls
+
+1. **`dangerouslyForceHydrate: true` for route-sync purposes** — breaks the render model. Use `useEffect` + `useSetAtom` instead.
+2. **Moving the initial hydration into `useEffect`** — children reading the atom on first render would see the default (empty) value, causing flashes / hydration mismatches.
+3. **Using `currentPathnameAtom` as the trigger for data fetching** — the trigger is `router.asPath`, and the normalized authority is `props.currentPathname`. The atom is for downstream UI consumers only.
+4. **Updating page-data atoms one-by-one during a fetch** — always update atomically (success block) to avoid intermediate states visible to `PageView`.
+5. **Adding a guard like `if (new !== old) set(new)` for atoms** — unnecessary; Jotai already dedupes on `Object.is`.

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.5.1",
+  "version": "7.5.2-RC.0",
   "license": "MIT",
   "private": true,
   "scripts": {

+ 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

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

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

+ 21 - 1
apps/app/src/states/global/hydrate.ts

@@ -1,3 +1,5 @@
+import { useEffect } from 'react';
+import { useSetAtom } from 'jotai';
 import { useHydrateAtoms } from 'jotai/utils';
 
 import type { CommonEachProps, CommonInitialProps } from '~/pages/common-props';
@@ -69,11 +71,29 @@ export const useHydrateGlobalInitialAtoms = (
 export const useHydrateGlobalEachAtoms = (
   commonEachProps: CommonEachProps,
 ): void => {
+  // Initial hydration: ensure atoms are populated before children read them on first render
   const tuples = [
     createAtomTuple(currentPathnameAtom, commonEachProps.currentPathname),
     createAtomTuple(currentUserAtom, commonEachProps.currentUser),
     createAtomTuple(isMaintenanceModeAtom, commonEachProps.isMaintenanceMode),
   ];
+  useHydrateAtoms(tuples);
+
+  // Subsequent sync (e.g. route transitions): run in effect to avoid
+  // "setState during render of a different component" warnings
+  const setCurrentPathname = useSetAtom(currentPathnameAtom);
+  const setCurrentUser = useSetAtom(currentUserAtom);
+  const setIsMaintenanceMode = useSetAtom(isMaintenanceModeAtom);
+
+  useEffect(() => {
+    setCurrentPathname(commonEachProps.currentPathname);
+  }, [commonEachProps.currentPathname, setCurrentPathname]);
+
+  useEffect(() => {
+    setCurrentUser(commonEachProps.currentUser);
+  }, [commonEachProps.currentUser, setCurrentUser]);
 
-  useHydrateAtoms(tuples, { dangerouslyForceHydrate: true });
+  useEffect(() => {
+    setIsMaintenanceMode(commonEachProps.isMaintenanceMode);
+  }, [commonEachProps.isMaintenanceMode, setIsMaintenanceMode]);
 };

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

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

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

+ 6 - 14
biome.json

@@ -1,30 +1,22 @@
 {
   "$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",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs"
     ]
@@ -85,7 +77,7 @@
       "correctness": {
         "useUniqueElementIds": "warn"
       },
-      "nursery": {
+      "complexity": {
         "useMaxParams": "warn"
       }
     }

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.5.1",
+  "version": "7.5.2-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,

+ 37 - 37
pnpm-lock.yaml

@@ -22,8 +22,8 @@ importers:
   .:
     devDependencies:
       '@biomejs/biome':
-        specifier: ^2.2.6
-        version: 2.2.6
+        specifier: ^2.4.12
+        version: 2.4.12
       '@changesets/changelog-github':
         specifier: ^0.5.0
         version: 0.5.0(encoding@0.1.13)
@@ -2392,59 +2392,59 @@ packages:
     resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
     engines: {node: '>=18'}
 
-  '@biomejs/biome@2.2.6':
-    resolution: {integrity: sha512-yKTCNGhek0rL5OEW1jbLeZX8LHaM8yk7+3JRGv08my+gkpmtb5dDE+54r2ZjZx0ediFEn1pYBOJSmOdDP9xtFw==}
+  '@biomejs/biome@2.4.12':
+    resolution: {integrity: sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA==}
     engines: {node: '>=14.21.3'}
     hasBin: true
 
-  '@biomejs/cli-darwin-arm64@2.2.6':
-    resolution: {integrity: sha512-UZPmn3M45CjTYulgcrFJFZv7YmK3pTxTJDrFYlNElT2FNnkkX4fsxjExTSMeWKQYoZjvekpH5cvrYZZlWu3yfA==}
+  '@biomejs/cli-darwin-arm64@2.4.12':
+    resolution: {integrity: sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng==}
     engines: {node: '>=14.21.3'}
     cpu: [arm64]
     os: [darwin]
 
-  '@biomejs/cli-darwin-x64@2.2.6':
-    resolution: {integrity: sha512-HOUIquhHVgh/jvxyClpwlpl/oeMqntlteL89YqjuFDiZ091P0vhHccwz+8muu3nTyHWM5FQslt+4Jdcd67+xWQ==}
+  '@biomejs/cli-darwin-x64@2.4.12':
+    resolution: {integrity: sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A==}
     engines: {node: '>=14.21.3'}
     cpu: [x64]
     os: [darwin]
 
-  '@biomejs/cli-linux-arm64-musl@2.2.6':
-    resolution: {integrity: sha512-TjCenQq3N6g1C+5UT3jE1bIiJb5MWQvulpUngTIpFsL4StVAUXucWD0SL9MCW89Tm6awWfeXBbZBAhJwjyFbRQ==}
+  '@biomejs/cli-linux-arm64-musl@2.4.12':
+    resolution: {integrity: sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig==}
     engines: {node: '>=14.21.3'}
     cpu: [arm64]
     os: [linux]
     libc: [musl]
 
-  '@biomejs/cli-linux-arm64@2.2.6':
-    resolution: {integrity: sha512-BpGtuMJGN+o8pQjvYsUKZ+4JEErxdSmcRD/JG3mXoWc6zrcA7OkuyGFN1mDggO0Q1n7qXxo/PcupHk8gzijt5g==}
+  '@biomejs/cli-linux-arm64@2.4.12':
+    resolution: {integrity: sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw==}
     engines: {node: '>=14.21.3'}
     cpu: [arm64]
     os: [linux]
     libc: [glibc]
 
-  '@biomejs/cli-linux-x64-musl@2.2.6':
-    resolution: {integrity: sha512-1ZcBux8zVM3JhWN2ZCPaYf0+ogxXG316uaoXJdgoPZcdK/rmRcRY7PqHdAos2ExzvjIdvhQp72UcveI98hgOog==}
+  '@biomejs/cli-linux-x64-musl@2.4.12':
+    resolution: {integrity: sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew==}
     engines: {node: '>=14.21.3'}
     cpu: [x64]
     os: [linux]
     libc: [musl]
 
-  '@biomejs/cli-linux-x64@2.2.6':
-    resolution: {integrity: sha512-1HaM/dpI/1Z68zp8ZdT6EiBq+/O/z97a2AiHMl+VAdv5/ELckFt9EvRb8hDHpk8hUMoz03gXkC7VPXOVtU7faA==}
+  '@biomejs/cli-linux-x64@2.4.12':
+    resolution: {integrity: sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw==}
     engines: {node: '>=14.21.3'}
     cpu: [x64]
     os: [linux]
     libc: [glibc]
 
-  '@biomejs/cli-win32-arm64@2.2.6':
-    resolution: {integrity: sha512-h3A88G8PGM1ryTeZyLlSdfC/gz3e95EJw9BZmA6Po412DRqwqPBa2Y9U+4ZSGUAXCsnSQE00jLV8Pyrh0d+jQw==}
+  '@biomejs/cli-win32-arm64@2.4.12':
+    resolution: {integrity: sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig==}
     engines: {node: '>=14.21.3'}
     cpu: [arm64]
     os: [win32]
 
-  '@biomejs/cli-win32-x64@2.2.6':
-    resolution: {integrity: sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ==}
+  '@biomejs/cli-win32-x64@2.4.12':
+    resolution: {integrity: sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA==}
     engines: {node: '>=14.21.3'}
     cpu: [x64]
     os: [win32]
@@ -15419,39 +15419,39 @@ snapshots:
 
   '@bcoe/v8-coverage@1.0.2': {}
 
-  '@biomejs/biome@2.2.6':
+  '@biomejs/biome@2.4.12':
     optionalDependencies:
-      '@biomejs/cli-darwin-arm64': 2.2.6
-      '@biomejs/cli-darwin-x64': 2.2.6
-      '@biomejs/cli-linux-arm64': 2.2.6
-      '@biomejs/cli-linux-arm64-musl': 2.2.6
-      '@biomejs/cli-linux-x64': 2.2.6
-      '@biomejs/cli-linux-x64-musl': 2.2.6
-      '@biomejs/cli-win32-arm64': 2.2.6
-      '@biomejs/cli-win32-x64': 2.2.6
+      '@biomejs/cli-darwin-arm64': 2.4.12
+      '@biomejs/cli-darwin-x64': 2.4.12
+      '@biomejs/cli-linux-arm64': 2.4.12
+      '@biomejs/cli-linux-arm64-musl': 2.4.12
+      '@biomejs/cli-linux-x64': 2.4.12
+      '@biomejs/cli-linux-x64-musl': 2.4.12
+      '@biomejs/cli-win32-arm64': 2.4.12
+      '@biomejs/cli-win32-x64': 2.4.12
 
-  '@biomejs/cli-darwin-arm64@2.2.6':
+  '@biomejs/cli-darwin-arm64@2.4.12':
     optional: true
 
-  '@biomejs/cli-darwin-x64@2.2.6':
+  '@biomejs/cli-darwin-x64@2.4.12':
     optional: true
 
-  '@biomejs/cli-linux-arm64-musl@2.2.6':
+  '@biomejs/cli-linux-arm64-musl@2.4.12':
     optional: true
 
-  '@biomejs/cli-linux-arm64@2.2.6':
+  '@biomejs/cli-linux-arm64@2.4.12':
     optional: true
 
-  '@biomejs/cli-linux-x64-musl@2.2.6':
+  '@biomejs/cli-linux-x64-musl@2.4.12':
     optional: true
 
-  '@biomejs/cli-linux-x64@2.2.6':
+  '@biomejs/cli-linux-x64@2.4.12':
     optional: true
 
-  '@biomejs/cli-win32-arm64@2.2.6':
+  '@biomejs/cli-win32-arm64@2.4.12':
     optional: true
 
-  '@biomejs/cli-win32-x64@2.2.6':
+  '@biomejs/cli-win32-x64@2.4.12':
     optional: true
 
   '@braintree/sanitize-url@7.1.0': {}