Browse Source

Merge remote-tracking branch 'origin/master' into imprv/staff-credit

Yuki Takei 2 weeks ago
parent
commit
7e1b2d431a
72 changed files with 1862 additions and 440 deletions
  1. 32 2
      .claude/commands/kiro/spec-cleanup.md
  2. 31 2
      .claude/settings.json
  3. 5 5
      .github/mergify.yml
  4. 7 13
      .github/workflows/reusable-app-prod.yml
  5. 1 0
      .gitignore
  6. 324 0
      .kiro/specs/optimize-presentation/design.md
  7. 58 0
      .kiro/specs/optimize-presentation/requirements.md
  8. 84 0
      .kiro/specs/optimize-presentation/research.md
  9. 22 0
      .kiro/specs/optimize-presentation/spec.json
  10. 49 0
      .kiro/specs/optimize-presentation/tasks.md
  11. 39 2
      .kiro/steering/tech.md
  12. 11 1
      CHANGELOG.md
  13. 74 0
      apps/app/.claude/rules/package-dependencies.md
  14. 1 2
      apps/app/.claude/skills/build-optimization/SKILL.md
  15. 5 5
      apps/app/.claude/skills/vendor-styles-components/SKILL.md
  16. 2 1
      apps/app/.gitignore
  17. 8 0
      apps/app/AGENTS.md
  18. 30 0
      apps/app/bin/assemble-prod.sh
  19. 36 0
      apps/app/bin/check-next-symlinks.sh
  20. 4 13
      apps/app/docker/Dockerfile
  21. 1 1
      apps/app/docker/README.md
  22. 26 0
      apps/app/next.config.prod.cjs
  23. 56 59
      apps/app/next.config.ts
  24. 43 24
      apps/app/package.json
  25. 32 17
      apps/app/src/client/components/ReactMarkdownComponents/LightBox.tsx
  26. 9 2
      apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx
  27. 5 7
      apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx
  28. 60 57
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  29. 3 0
      apps/app/src/components/Layout/AdminLayout.tsx
  30. 35 19
      apps/app/src/features/admin/states/socket-io.ts
  31. 2 1
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  32. 1 1
      apps/app/src/server/service/config-manager/config-definition.ts
  33. 1 9
      apps/app/src/server/util/project-dir-utils.ts
  34. 3 2
      apps/app/src/services/renderer/remark-plugins/emoji.ts
  35. 17 0
      apps/app/test/setup/mongo/global-setup.ts
  36. 3 10
      apps/app/test/setup/mongo/index.ts
  37. 6 0
      apps/app/test/setup/mongo/utils.ts
  38. 1 4
      apps/app/tsconfig.json
  39. 5 5
      apps/app/turbo.json
  40. 4 4
      apps/app/vite.vendor-styles-components.ts
  41. 2 0
      apps/app/vitest.workspace.mts
  42. 1 1
      apps/slackbot-proxy/package.json
  43. 1 1
      biome.json
  44. 1 1
      package.json
  45. 1 1
      packages/custom-icons/package.json
  46. 2 1
      packages/editor/package.json
  47. 28 5
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/EmojiButton.tsx
  48. 3 36
      packages/editor/src/client/services-internal/extensions/emojiAutocompletionSettings.ts
  49. 9 0
      packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts
  50. 3 3
      packages/editor/turbo.json
  51. 1 0
      packages/emoji-mart-data/.gitignore
  52. 95 0
      packages/emoji-mart-data/AGENTS.md
  53. 1 0
      packages/emoji-mart-data/CLAUDE.md
  54. 84 0
      packages/emoji-mart-data/bin/extract.ts
  55. 24 0
      packages/emoji-mart-data/package.json
  56. 16 0
      packages/emoji-mart-data/turbo.json
  57. 2 0
      packages/presentation/.gitignore
  58. 2 2
      packages/presentation/package.json
  59. 70 0
      packages/presentation/scripts/extract-marpit-css.ts
  60. 5 7
      packages/presentation/src/client/components/GrowiSlides.tsx
  61. 17 3
      packages/presentation/src/client/components/Slides.tsx
  62. 2 0
      packages/presentation/src/client/consts/index.ts
  63. 4 1
      packages/presentation/src/client/services/growi-marpit.ts
  64. 27 0
      packages/presentation/turbo.json
  65. 1 1
      packages/preset-templates/package.json
  66. 1 1
      packages/preset-themes/package.json
  67. 1 1
      packages/remark-attachment-refs/package.json
  68. 1 1
      packages/remark-drawio/package.json
  69. 1 1
      packages/remark-lsx/package.json
  70. 1 1
      packages/slack/package.json
  71. 1 1
      packages/ui/package.json
  72. 318 103
      pnpm-lock.yaml

+ 32 - 2
.claude/commands/kiro/spec-cleanup.md

@@ -41,6 +41,11 @@ Clean up and organize specification documents for feature **$1** after implement
 - Read all core files first
 - Read other files to understand their content and value
 
+**Determine target language**:
+- Read `spec.json` and extract the `language` field (e.g., `"ja"`, `"en"`)
+- This is the language ALL spec document content must be written in
+- Note: code comments within code blocks are exempt (must stay in English per project rules)
+
 **Verify implementation status**:
 - Check that tasks are marked complete `[x]` in tasks.md
 - If implementation incomplete, warn user and ask to confirm cleanup
@@ -86,6 +91,15 @@ Clean up and organize specification documents for feature **$1** after implement
      * Known limitations
    - Check if content from other files should be migrated here
 
+5. **Language audit** (compare actual language vs. `spec.json.language`):
+   - For each markdown file, scan prose content (headings, paragraphs, list items) and detect the written language
+   - Flag any file or section whose language does **not** match the target language
+   - Exemptions — do NOT flag:
+     * Content inside fenced code blocks (` ``` `) — code comments must stay in English
+     * Inline code spans (`` `...` ``)
+     * Proper nouns, technical terms, and identifiers that are always written in English
+   - Collect flagged items into a **translation plan**: file name, approximate line range, detected language, and a brief excerpt
+
 ### Step 3: Interactive Confirmation
 
 **Present cleanup plan to user**:
@@ -110,6 +124,14 @@ For each file and section identified in Step 2, ask:
 - "design.md: Delete 'Security Considerations' section (lines X-Y)? [Y/n]"
 - "design.md: Keep Architecture diagrams (essential for refactoring)? [Y/n]"
 
+**Translation confirmation** (if language mismatches were found in Step 2):
+- Show summary: "Found content in language(s) other than `{target_language}` in the following files:"
+  - List each flagged file with line range and a short excerpt
+- Ask: "Translate mismatched content to `{target_language}`? [Y/n]"
+  - If Y: translate all flagged sections in Step 4
+  - If n: skip translation (leave files as-is)
+- Note: code blocks are never translated
+
 **Batch similar decisions**:
 - Group related sections (e.g., all "delete implementation details" decisions)
 - Allow user to approve categories rather than individual items
@@ -153,7 +175,14 @@ For each file and section identified in Step 2, ask:
    - Preserve architecture diagrams and component interfaces
    - Keep design decisions and rationale sections
 
-5. **Update spec.json metadata**:
+5. **Translate language-mismatched content** (if approved):
+   - For each flagged file and section, translate prose content to the target language
+   - **Never translate**: content inside fenced code blocks or inline code spans
+   - Preserve all Markdown formatting (headings, bold, lists, links, etc.)
+   - After translation, verify the overall document reads naturally in the target language
+   - Document translated files in the cleanup summary
+
+6. **Update spec.json metadata**:
    - Set `phase: "implementation-complete"` (if not already set)
    - Add `cleanup_completed: true` flag
    - Update `updated_at` timestamp
@@ -176,6 +205,7 @@ For each file and section identified in Step 2, ask:
 - ✅ research.md: Added Session 2 discoveries + salvaged content (180 lines added)
 - ✅ requirements.md: Simplified 6 requirements (350 lines → 180 lines)
 - ✅ design.md: Removed 4 sections, added constraints + salvaged content (250 lines removed, 100 added)
+- ✅ requirements.md: Translated mismatched sections to {target_language}
 
 ### Information Salvaged
 - Implementation discoveries from validation-report.md → research.md
@@ -196,7 +226,7 @@ For each file and section identified in Step 2, ask:
 ## Critical Constraints
 
 - **User approval required**: Never delete content without explicit confirmation
-- **Language consistency**: Use language specified in spec.json for all updates
+- **Language consistency**: All prose content must be written in the language specified in `spec.json.language`; translate any mismatched sections (code blocks exempt)
 - **Preserve history**: Don't delete discovery rationale or design decisions
 - **Balance brevity with completeness**: Remove redundancy but keep essential context
 - **Interactive workflow**: Pause for user input rather than making assumptions

+ 31 - 2
.claude/settings.json

@@ -1,4 +1,34 @@
 {
+  "permissions": {
+    "allow": [
+      "Bash(node --version)",
+      "Bash(npm --version)",
+      "Bash(npm view *)",
+      "Bash(pnpm --version)",
+      "Bash(turbo --version)",
+      "Bash(turbo run build)",
+      "Bash(turbo run lint)",
+      "Bash(pnpm run lint:*)",
+      "Bash(pnpm vitest run *)",
+      "Bash(pnpm biome check *)",
+      "Bash(cat *)",
+      "Bash(echo *)",
+      "Bash(find *)",
+      "Bash(grep *)",
+      "Bash(git diff *)",
+      "Bash(gh issue view *)",
+      "Bash(gh pr view *)",
+      "Bash(gh pr diff *)",
+      "Bash(ls *)",
+      "WebFetch(domain:github.com)",
+      "mcp__context7__*",
+      "mcp__plugin_context7_*",
+      "mcp__github__*",
+      "WebSearch",
+      "WebFetch"
+    ]
+  },
+  "enableAllProjectMcpServers": true,
   "hooks": {
     "SessionStart": [
       {
@@ -17,8 +47,7 @@
           {
             "type": "command",
             "command": "if [[ \"$FILE\" == */apps/* ]] || [[ \"$FILE\" == */packages/* ]]; then REPO_ROOT=$(echo \"$FILE\" | sed 's|/\\(apps\\|packages\\)/.*|/|'); cd \"$REPO_ROOT\" && pnpm biome check --write \"$FILE\" 2>/dev/null || true; fi",
-            "timeout": 30,
-            "description": "Auto-format edited files in apps/* and packages/* with Biome"
+            "timeout": 30
           }
         ]
       }

+ 5 - 5
.github/mergify.yml

@@ -6,17 +6,17 @@ queue_rules:
       - check-success ~= ci-app-launch-dev
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node20 /
+      - -check-failure ~= test-prod-node24 /
     merge_conditions:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test
       - check-success ~= ci-app-launch-dev
-      - check-success = test-prod-node20 / build-prod
-      - check-success ~= test-prod-node20 / launch-prod
-      - check-success ~= test-prod-node20 / run-playwright
+      - check-success = test-prod-node24 / build-prod
+      - check-success ~= test-prod-node24 / launch-prod
+      - check-success ~= test-prod-node24 / run-playwright
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node20 /
+      - -check-failure ~= test-prod-node24 /
 
 pull_request_rules:
   - name: Automatic queue to merge

+ 7 - 13
.github/workflows/reusable-app-prod.yml

@@ -57,25 +57,18 @@ jobs:
       env:
         ANALYZE: 1
 
-    - name: Assembling all dependencies
-      run: |
-        rm -rf out
-        pnpm deploy out --prod --legacy --filter @growi/app
-        rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
+    - name: Assemble production artifacts
+      run: bash apps/app/bin/assemble-prod.sh
 
-    - name: Resolve .next/node_modules symlinks
-      run: |
-        if [ -d apps/app/.next/node_modules ]; then
-          cp -rL apps/app/.next/node_modules apps/app/.next/node_modules_resolved
-          rm -rf apps/app/.next/node_modules
-          mv apps/app/.next/node_modules_resolved apps/app/.next/node_modules
-        fi
+    - name: Check for broken symlinks in .next/node_modules
+      run: bash apps/app/bin/check-next-symlinks.sh
 
     - name: Archive production files
       id: archive-prod-files
       run: |
         tar -zcf production.tar.gz --exclude ./apps/app/.next/cache \
           package.json \
+          node_modules \
           apps/app/.next \
           apps/app/config \
           apps/app/dist \
@@ -84,6 +77,7 @@ jobs:
           apps/app/tmp \
           apps/app/.env.production* \
           apps/app/node_modules \
+          apps/app/next.config.js \
           apps/app/package.json
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
 
@@ -179,7 +173,7 @@ jobs:
     container:
       # Match the Playwright version
       # https://github.com/microsoft/playwright/issues/20010
-      image: mcr.microsoft.com/playwright:v1.49.1-jammy
+      image: mcr.microsoft.com/playwright:v1.58.2-jammy
 
     strategy:
       fail-fast: false

+ 1 - 0
.gitignore

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

+ 324 - 0
.kiro/specs/optimize-presentation/design.md

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

+ 58 - 0
.kiro/specs/optimize-presentation/requirements.md

@@ -0,0 +1,58 @@
+# Requirements Document
+
+## Introduction
+
+The GROWI presentation feature (`@growi/presentation` package) statically imports `@marp-team/marp-core` (~524KB) and `@marp-team/marpit` (~372KB) whenever any slide component loads, even when Marp rendering is not needed. This is because both `MarpSlides` and `GrowiSlides` components statically import `growi-marpit.ts`, which instantiates Marp objects at module scope.
+
+The goal is to decouple heavy Marp dependencies so they are only loaded when a page explicitly uses `marp: true` in its frontmatter, reducing the async chunk size for the common non-Marp slide rendering path and improving overall bundle efficiency.
+
+## Requirements
+
+### Requirement 1: Decouple GrowiSlides from Marp Runtime Dependencies
+
+**Objective:** As a developer, I want GrowiSlides to render without loading `@marp-team/marp-core` or `@marp-team/marpit`, so that non-Marp slide pages do not incur unnecessary module loading overhead.
+
+#### Acceptance Criteria
+1. The `@growi/presentation` build output for GrowiSlides shall not contain import references to `@marp-team/marp-core` or `@marp-team/marpit`.
+2. When a slide page without `marp: true` is rendered, the Presentation module shall render GrowiSlides without loading `@marp-team/marp-core` or `@marp-team/marpit` modules.
+3. The Presentation module shall provide the Marp base CSS (previously generated by `marpit.render('')`) as pre-extracted static string constants, so that GrowiSlides can apply Marp container styling without a runtime Marp dependency.
+4. The `MARP_CONTAINER_CLASS_NAME` constant shall be defined in a shared constants module, not in `growi-marpit.ts`, to avoid transitive Marp imports.
+
+### Requirement 2: Dynamic Loading of MarpSlides
+
+**Objective:** As a developer, I want MarpSlides to be loaded dynamically (on demand), so that the Marp rendering engine is only fetched when a page actually uses Marp.
+
+#### Acceptance Criteria
+1. When a slide page with `marp: true` is rendered, the Presentation module shall dynamically load MarpSlides and render Marp content correctly.
+2. While MarpSlides is loading, the Presentation module shall display a loading indicator (Suspense fallback).
+3. When a slide page without `marp: true` is rendered, the Presentation module shall not trigger the dynamic import of MarpSlides.
+
+### Requirement 3: Build-Time CSS Extraction
+
+**Objective:** As a developer, I want the Marp base CSS to be extracted at build time via an automated script, so that the pre-extracted CSS stays synchronized with the installed `@marp-team/marp-core` version.
+
+#### Acceptance Criteria
+1. The `@growi/presentation` package shall include a build-time script that generates Marp base CSS constants by invoking `slideMarpit.render('')` and `presentationMarpit.render('')`.
+2. When `pnpm run build` is executed for `@growi/presentation`, the build pipeline shall regenerate the CSS constants before compiling source files.
+3. The generated CSS constants file shall be committed to the repository so that `dev` mode works without running the extraction script first.
+
+### Requirement 4: Functional Equivalence
+
+**Objective:** As a user, I want the presentation feature to behave identically after optimization, so that existing Marp and non-Marp presentations continue to work without regression.
+
+#### Acceptance Criteria
+1. When a page with `marp: true` frontmatter is viewed inline, the Presentation module shall render Marp slides with correct styling.
+2. When a page with `slide: true` frontmatter (without `marp: true`) is viewed inline, the Presentation module shall render GrowiSlides with correct styling.
+3. When the presentation modal is opened for a Marp page, the Presentation module shall render Marp slides in the modal with correct fullscreen behavior.
+4. When the presentation modal is opened for a non-Marp slide page, the Presentation module shall render GrowiSlides in the modal with correct fullscreen behavior.
+5. When a non-slide page is viewed, the Presentation module shall not load any slide rendering components (existing lazy-loading behavior preserved).
+
+### Requirement 5: Build Verification
+
+**Objective:** As a developer, I want to verify that the optimization achieves its goal, so that Marp module separation can be confirmed in CI and during development.
+
+#### Acceptance Criteria
+1. The `@growi/presentation` package shall build successfully with `pnpm run build`.
+2. The `@growi/app` package shall build successfully with `turbo run build --filter @growi/app`.
+3. The built `GrowiSlides.js` output shall contain no references to `@marp-team/marp-core` or `@marp-team/marpit`.
+4. The built `Slides.js` output shall contain a dynamic `import()` expression for `MarpSlides`.

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

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

+ 22 - 0
.kiro/specs/optimize-presentation/spec.json

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

+ 49 - 0
.kiro/specs/optimize-presentation/tasks.md

@@ -0,0 +1,49 @@
+# Implementation Plan
+
+- [x] 1. Set up shared constants and build-time CSS extraction infrastructure
+- [x] 1.1 Move the Marp container class name constant to the shared constants module and update growi-marpit to import from there
+  - Add the `MARP_CONTAINER_CLASS_NAME` string constant to the existing shared constants module in the presentation package
+  - Update growi-marpit to import the constant from the shared module instead of defining it locally
+  - Re-export the constant from growi-marpit for backward compatibility with MarpSlides
+  - _Requirements: 1.4_
+
+- [x] 1.2 Create the build-time CSS extraction script
+  - Write a Node.js ESM script that instantiates Marp with the same configuration as growi-marpit (container classes, inlineSVG, emoji/html/math disabled)
+  - The script renders empty strings through both slide and presentation Marp instances to extract their CSS output
+  - Write the CSS strings as exported TypeScript constants to the constants directory
+  - Include a file header comment indicating the file is auto-generated and how to regenerate it
+  - Validate that extracted CSS is non-empty before writing
+  - _Requirements: 3.1_
+
+- [x] 1.3 Wire the extraction script into the build pipeline and generate the initial CSS file
+  - Add a `pre:build:src` script entry in the presentation package's package.json that runs the extraction script before the main Vite build
+  - Execute the script once to generate the initial pre-extracted CSS constants file
+  - Commit the generated file so that dev mode works without running extraction first
+  - _Requirements: 3.2, 3.3_
+
+- [x] 2. (P) Decouple GrowiSlides from Marp runtime dependencies
+  - Replace the growi-marpit import in GrowiSlides with imports from the shared constants module and the pre-extracted CSS constants
+  - Replace the runtime `marpit.render('')` call with a lookup of the pre-extracted CSS constant based on the presentation mode flag
+  - After this change, GrowiSlides must have no import path leading to `@marp-team/marp-core` or `@marp-team/marpit`
+  - Depends on task 1 (shared constants and CSS file must exist)
+  - _Requirements: 1.1, 1.2, 1.3_
+
+- [x] 3. (P) Add dynamic import for MarpSlides in the Slides routing component
+  - Replace the static import of MarpSlides with a React.lazy dynamic import that resolves the named export
+  - Wrap the MarpSlides rendering branch in a Suspense boundary with a simple loading fallback
+  - Keep GrowiSlides as a static import (the common, lightweight path)
+  - The dynamic import ensures MarpSlides and its transitive Marp dependencies are only loaded when `hasMarpFlag` is true
+  - Depends on task 1 (shared constants must exist); parallel-safe with task 2 (different file)
+  - _Requirements: 2.1, 2.2, 2.3_
+
+- [x] 4. Build verification and functional validation
+- [x] 4.1 Build the presentation package and verify module separation in the output
+  - Run the presentation package build and confirm it succeeds
+  - Inspect the built GrowiSlides output file to confirm it contains no references to `@marp-team/marp-core` or `@marp-team/marpit`
+  - Inspect the built Slides output file to confirm it contains a dynamic `import()` expression for MarpSlides
+  - _Requirements: 5.1, 5.3, 5.4_
+
+- [x] 4.2 Build the main GROWI application and verify successful compilation
+  - Run the full app build to confirm no regressions from the presentation package changes
+  - Verify that both Marp and non-Marp slide rendering paths are intact by checking the build completes without type errors
+  - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 5.2_

+ 39 - 2
.kiro/steering/tech.md

@@ -6,7 +6,7 @@ See: `.claude/skills/tech-stack/SKILL.md` (auto-loaded by Claude Code)
 
 ### Bundler Strategy (Project-Wide Decision)
 
-GROWI uses **Turbopack** (Next.js 16 default) for development. Webpack fallback is available via `USE_WEBPACK=1` environment variable for debugging. Production builds still use `next build --webpack`. All custom webpack loaders/plugins have been migrated to Turbopack equivalents (`turbopack.rules`, `turbopack.resolveAlias`). See `apps/app/.claude/skills/build-optimization/SKILL.md` for details.
+GROWI uses **Turbopack** (Next.js 16 default) for **both development and production builds** (`next build` without flags). Webpack fallback is available via `USE_WEBPACK=1` environment variable for debugging only. All custom webpack loaders/plugins have been migrated to Turbopack equivalents (`turbopack.rules`, `turbopack.resolveAlias`). See `apps/app/.claude/skills/build-optimization/SKILL.md` for details.
 
 ### Import Optimization Principles
 
@@ -16,7 +16,44 @@ To prevent module count regression across the monorepo:
 - **Lightweight replacements** — prefer small single-purpose packages over large multi-feature libraries
 - **Server-client boundary** — never import server-only code from client modules; extract client-safe utilities if needed
 
+### Turbopack Externalisation Rule (`apps/app/package.json`)
+
+**Any package that is reachable via a static `import` statement in SSR-executed code must be listed under `dependencies`, not `devDependencies`.**
+
+Turbopack externalises such packages to `.next/node_modules/` (symlinks into the pnpm store). `pnpm deploy --prod` only includes `dependencies`; packages in `devDependencies` are absent from the deploy output, causing `ERR_MODULE_NOT_FOUND` at production server startup.
+
+**SSR-executed code** = any module that Turbopack statically traces from a Pages Router page component, `_app.page.tsx`, or a server-side utility — without crossing a `dynamic(() => import(...), { ssr: false })` boundary.
+
+**Making a package devDep-eligible:**
+1. Wrap the consuming component with `dynamic(() => import('...'), { ssr: false })`, **or**
+2. Replace the runtime dependency with a static asset (e.g., extract data to a committed JSON file), **or**
+3. Change the import to a dynamic `import()` inside a `useEffect` (browser-only execution).
+
+**Packages justified to stay in `dependencies`** (SSR-reachable static imports as of v7.5):
+- `react-toastify` — `toastr.ts` static `{ toast }` import reachable from SSR pages; async refactor would break API surface
+- `bootstrap` — still externalised despite `useEffect`-guarded `import()` in `_app.page.tsx`; Turbopack traces call sites statically
+- `diff2html` — still externalised despite `ssr: false` on `RevisionDiff`; static import analysis reaches it
+- `react-dnd`, `react-dnd-html5-backend` — still externalised despite DnD provider wrapped with `ssr: false`
+- `@handsontable/react` — still externalised despite `useEffect` dynamic import in `HandsontableModal`
+- `i18next-http-backend`, `i18next-localstorage-backend`, `react-dropzone` — no direct `src/` imports but appear via transitive imports
+- `@codemirror/state`, `@headless-tree/*`, `@tanstack/react-virtual`, `downshift`, `fastest-levenshtein`, `pretty-bytes`, `react-copy-to-clipboard`, `react-hook-form`, `react-input-autosize`, `simplebar-react` — statically imported in SSR-rendered components
+
+### Production Assembly Pattern
+
+`assemble-prod.sh` produces the release artifact via **workspace-root staging** (not `apps/app/` staging):
+
+```
+pnpm deploy out --prod --legacy   → self-contained out/node_modules/ (pnpm v10)
+rm -rf node_modules
+mv out/node_modules node_modules  → workspace root is now prod-only
+ln -sfn ../../node_modules apps/app/node_modules  → compatibility symlink
+```
+
+The release image includes `node_modules/` at workspace root alongside `apps/app/`. Turbopack's `.next/node_modules/` symlinks (pointing `../../../../node_modules/.pnpm/`) resolve naturally without any sed-based rewriting. `apps/app/node_modules` is a symlink to `../../node_modules` for migration script and Node.js `require()` compatibility.
+
+**pnpm version sensitivity**: `--legacy` produces self-contained symlinks in pnpm v10+. Downgrading below v10 may break the assembly. After running `assemble-prod.sh` locally, run `pnpm install` to restore the development environment.
+
 For apps/app-specific build optimization details (webpack config, null-loader rules, SuperJSON architecture, module count KPI), see `apps/app/.claude/skills/build-optimization/SKILL.md`.
 
 ---
-_Updated: 2026-03-03. apps/app details moved to `apps/app/.claude/skills/build-optimization/SKILL.md`._
+_Updated: 2026-03-17. Turbopack now used for production builds; expanded justified-deps list; added Production Assembly Pattern._

+ 11 - 1
CHANGELOG.md

@@ -1,9 +1,19 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.4.6...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.7...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.4.7](https://github.com/growilabs/compare/v7.4.6...v7.4.7) - 2026-03-23
+
+### 💎 Features
+
+* feat: Prevent inline mime type sniffing vulnerabilities (#10087) @arvid-e
+
+### 🐛 Bug Fixes
+
+* fix(editor): Disable bracketMatching to prevent IME composition rendering corruption (#10900) @miya
+
 ## [v7.4.6](https://github.com/growilabs/compare/v7.4.5...v7.4.6) - 2026-03-10
 
 ### 🐛 Bug Fixes

+ 74 - 0
apps/app/.claude/rules/package-dependencies.md

@@ -0,0 +1,74 @@
+# Package Dependency Classification (Turbopack)
+
+## The Rule
+
+> Any package that appears in `apps/app/.next/node_modules/` after a production build MUST be listed under `dependencies`, not `devDependencies`.
+
+Turbopack externalises packages by generating runtime symlinks in `.next/node_modules/`. `pnpm deploy --prod` excludes `devDependencies`, so any externalised package missing from `dependencies` causes `ERR_MODULE_NOT_FOUND` in production.
+
+## How to Classify a New Package
+
+**Step 1 — Build and check:**
+
+```bash
+turbo run build --filter @growi/app
+ls apps/app/.next/node_modules/ | grep <package-name>
+```
+
+- **Found** → `dependencies`
+- **Not found** → `devDependencies` (if runtime code) or `devDependencies` (if build/test only)
+
+**Step 2 — If unsure, check the import site:**
+
+| Import pattern | Classification |
+|---|---|
+| `import foo from 'pkg'` at module level in SSR-executed code | `dependencies` |
+| `import type { Foo } from 'pkg'` only | `devDependencies` (type-erased at build) |
+| `await import('pkg')` inside `useEffect` / event handler | Check `.next/node_modules/` — may still be externalised |
+| Used only in `*.spec.ts`, build scripts, or CI | `devDependencies` |
+
+## Common Misconceptions
+
+**`dynamic({ ssr: false })` does NOT prevent Turbopack externalisation.**
+It skips HTML rendering for that component but Turbopack still externalises packages found via static import analysis inside the dynamically-loaded file.
+
+**`useEffect`-guarded `import()` does NOT guarantee devDependencies.**
+Bootstrap and i18next backends are loaded this way yet still appear in `.next/node_modules/` due to transitive imports.
+
+## Packages Confirmed as devDependencies (Verified)
+
+These were successfully removed from production artifact by eliminating their SSR import path:
+
+| Package | Technique |
+|---|---|
+| `fslightbox-react` | Replaced static import with `import()` inside `useEffect` in `LightBox.tsx` |
+| `socket.io-client` | Replaced static import with `await import()` inside `useEffect` in `admin/states/socket-io.ts` |
+| `@emoji-mart/data` | Replaced runtime import with bundled static JSON (`emoji-native-lookup.json`) |
+
+## Verifying the Production Artifact
+
+### Level 1 — Externalisation check (30–60 s, local, incremental)
+
+Just want to know if a package gets externalised by Turbopack?
+
+```bash
+turbo run build --filter @growi/app
+ls apps/app/.next/node_modules/ | grep <package-name>
+# Found → dependencies required
+# Not found → devDependencies is safe
+```
+
+Turbopack build is incremental via cache, so subsequent runs after the first are fast.
+
+### Level 2 — CI (`reusable-app-prod.yml`, authoritative)
+
+Trigger via `workflow_dispatch` before merging. Runs two jobs:
+
+1. **`build-prod`**: `turbo run build` → `assemble-prod.sh` → **`check-next-symlinks.sh`** → archives production tarball
+2. **`launch-prod`**: extracts the tarball into a clean isolated directory (no workspace-root `node_modules`), runs `pnpm run server:ci`
+
+`check-next-symlinks.sh` scans every symlink in `.next/node_modules/` and fails the build if any are broken (except `fslightbox-react` which is intentionally broken but harmless). This catches classification errors regardless of which code paths are exercised at runtime.
+
+`server:ci` = `node dist/server/app.js --ci`: the server starts fully (loading all modules), then immediately exits with code 0. If any module fails to load (`ERR_MODULE_NOT_FOUND`), the process exits with code 1, failing the CI job.
+
+This exactly matches Docker production (no workspace fallback). A `build-prod` or `launch-prod` failure definitively means a missing `dependencies` entry.

+ 1 - 2
apps/app/.claude/skills/build-optimization/SKILL.md

@@ -1,7 +1,7 @@
 ---
 name: build-optimization
 description: GROWI apps/app Turbopack configuration, module optimization, and build measurement tooling. Auto-invoked when working in apps/app.
-user-invokable: false
+user-invocable: false
 ---
 
 # Build Optimization (apps/app)
@@ -101,7 +101,6 @@ Global CSS cannot be imported from files other than `_app.page.tsx` under Turbop
 
 ## Module Optimization Configuration
 
-- `bundlePagesRouterDependencies: true` — bundles server-side dependencies for Pages Router
 - `serverExternalPackages: ['handsontable']` — packages excluded from server-side bundling
 - `optimizePackageImports` — 11 `@growi/*` packages configured (expansion to third-party packages was tested and reverted — it increased dev module count)
 

+ 5 - 5
apps/app/.claude/skills/vendor-styles-components/SKILL.md

@@ -23,15 +23,15 @@ Centralizing all vendor CSS in `_app` would degrade FCP for pages that don't nee
 ### Components Track (component-specific CSS)
 
 - **For**: CSS needed only by specific components
-- **Mechanism**: Vite precompiles `*.vendor-styles.ts` entry points into `*.vendor-styles.prebuilt.js` using `?inline` CSS import suffix
+- **Mechanism**: Vite precompiles `*.vendor-styles.ts` entry points into `*.vendor-styles.prebuilt.ts` using `?inline` CSS import suffix
 - **Output**: Pure JS modules (no CSS imports) — Turbopack sees them as regular JS
 
 ## How It Works
 
 1. **Entry point** (`ComponentName.vendor-styles.ts`): imports CSS via Vite `?inline` suffix, which inlines CSS as a string
 2. **Runtime injection**: the entry point creates a `<style>` tag and appends CSS to `document.head`
-3. **Vite prebuild** (`pre:styles-components` Turborepo task): compiles entry points into `*.vendor-styles.prebuilt.js`
-4. **Component import**: imports the `.prebuilt.js` file instead of raw CSS
+3. **Vite prebuild** (`pre:styles-components` Turborepo task): compiles entry points into `*.vendor-styles.prebuilt.ts`
+4. **Component import**: imports the `.prebuilt.ts` file instead of raw CSS
 
 ### Entry Point Template
 
@@ -96,7 +96,7 @@ When vendor CSS references external assets (e.g., KaTeX `@font-face` with `url(f
 - The `moveAssetsToPublic` plugin (in `vite.vendor-styles-components.ts`) relocates them to `public/static/fonts/`
 - URL references in prebuilt JS are rewritten from `/assets/` to `/static/fonts/`
 - Fonts are served by the existing `express.static(crowi.publicDir)` middleware
-- Both `public/static/fonts/` and `src/**/*.vendor-styles.prebuilt.js` are git-ignored
+- Both `public/static/fonts/` and `src/**/*.vendor-styles.prebuilt.ts` are git-ignored
 
 ## Build Pipeline Integration
 
@@ -106,7 +106,7 @@ turbo.json tasks:
   dev:pre:styles-components  →  dev (dependency)
 
 Inputs:  vite.vendor-styles-components.ts, src/**/*.vendor-styles.ts, package.json
-Outputs: src/**/*.vendor-styles.prebuilt.js, public/static/fonts/**
+Outputs: src/**/*.vendor-styles.prebuilt.ts, public/static/fonts/**
 ```
 
 ## Important Caveats

+ 2 - 1
apps/app/.gitignore

@@ -2,6 +2,7 @@
 /.next/
 /out/
 next-env.d.ts
+next.config.js
 
 # test
 .reg
@@ -15,7 +16,7 @@ next-env.d.ts
 /public/static/styles
 /public/uploads
 /src/styles/prebuilt
-/src/**/*.vendor-styles.prebuilt.js
+/src/**/*.vendor-styles.prebuilt.*
 /tmp/
 
 # cache

+ 8 - 0
apps/app/AGENTS.md

@@ -159,3 +159,11 @@ Plus all global skills (monorepo-overview, tech-stack).
 ---
 
 For detailed patterns and examples, refer to the Skills in `.claude/skills/`.
+
+## Rules (Always Applied)
+
+The following rules in `.claude/rules/` are always applied when working in this directory:
+
+| Rule | Description |
+|------|-------------|
+| **package-dependencies** | Turbopack dependency classification — when to use `dependencies` vs `devDependencies`, verification procedure |

+ 30 - 0
apps/app/bin/assemble-prod.sh

@@ -0,0 +1,30 @@
+#!/bin/bash
+# Assemble production artifacts for GROWI app.
+# Run from the workspace root.
+set -euo pipefail
+
+echo "[1/4] Collecting production dependencies..."
+rm -rf out
+pnpm deploy out --prod --legacy --filter @growi/app
+echo "[1/4] Done."
+
+echo "[2/4] Reorganizing node_modules..."
+rm -rf node_modules
+mv out/node_modules node_modules
+rm -rf apps/app/node_modules
+ln -sfn ../../node_modules apps/app/node_modules
+rm -rf out
+echo "[2/4] Done."
+
+echo "[3/4] Removing build cache..."
+rm -rf apps/app/.next/cache
+echo "[3/4] Done."
+
+# Provide a CJS runtime config so the production server can load it without TypeScript.
+# next.config.js takes precedence over next.config.ts in Next.js, so the .ts file
+# is left in place but effectively ignored at runtime.
+echo "[4/4] Installing runtime next.config.js..."
+cp apps/app/next.config.prod.cjs apps/app/next.config.js
+echo "[4/4] Done."
+
+echo "Assembly complete."

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

@@ -0,0 +1,36 @@
+#!/bin/bash
+# Check that all .next/node_modules/ symlinks resolve correctly after assemble-prod.sh.
+# Usage: bash apps/app/bin/check-next-symlinks.sh (from monorepo root)
+set -euo pipefail
+
+NEXT_MODULES="apps/app/.next/node_modules"
+
+# Packages that are intentionally broken symlinks.
+# These are only imported via useEffect + dynamic import() and never accessed during SSR.
+ALLOWED_BROKEN=(
+  fslightbox-react
+  @emoji-mart/data
+  @emoji-mart/react
+)
+
+# Build a grep -v pattern from the allowlist
+grep_args=()
+for pkg in "${ALLOWED_BROKEN[@]}"; do
+  grep_args+=(-e "$pkg")
+done
+
+broken=$(find "$NEXT_MODULES" -maxdepth 2 -type l | while read -r link; do
+  linkdir=$(dirname "$link")
+  target=$(readlink "$link")
+  resolved=$(cd "$linkdir" 2>/dev/null && realpath -m "$target" 2>/dev/null || echo "UNRESOLVABLE")
+  { [ "$resolved" = "UNRESOLVABLE" ] || [ ! -e "$resolved" ]; } && echo "BROKEN: $link"
+done | grep -v "${grep_args[@]}" || true)
+
+if [ -n "$broken" ]; then
+  echo "ERROR: Broken symlinks found in $NEXT_MODULES:"
+  echo "$broken"
+  echo "Move these packages from devDependencies to dependencies in apps/app/package.json."
+  exit 1
+fi
+
+echo "OK: All $NEXT_MODULES symlinks resolve correctly."

+ 4 - 13
apps/app/docker/Dockerfile

@@ -91,25 +91,16 @@ RUN turbo run clean
 RUN turbo run build --filter @growi/app
 
 # Produce artifacts
-RUN pnpm deploy out --prod --legacy --filter @growi/app
-RUN rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
-RUN rm -rf apps/app/.next/cache
-
-# Resolve .next/node_modules symlinks to real files.
-# Turbopack generates symlinks pointing to the pnpm virtual store (node_modules/.pnpm/),
-# which will not exist in the release image.
-RUN if [ -d apps/app/.next/node_modules ]; then \
-      cp -rL apps/app/.next/node_modules apps/app/.next/node_modules_resolved && \
-      rm -rf apps/app/.next/node_modules && \
-      mv apps/app/.next/node_modules_resolved apps/app/.next/node_modules; \
-    fi
+RUN bash apps/app/bin/assemble-prod.sh
 
 # Stage artifacts into a clean directory for COPY --from
 RUN mkdir -p /tmp/release/apps/app && \
   cp package.json /tmp/release/ && \
+  cp -a node_modules /tmp/release/ && \
   cp -a apps/app/.next apps/app/config apps/app/dist apps/app/public \
-       apps/app/resource apps/app/tmp apps/app/next.config.js \
+       apps/app/resource apps/app/tmp \
        apps/app/package.json apps/app/node_modules \
+       apps/app/next.config.js \
        /tmp/release/apps/app/ && \
   (cp apps/app/.env.production* /tmp/release/apps/app/ 2>/dev/null || true)
 

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

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

+ 26 - 0
apps/app/next.config.prod.cjs

@@ -0,0 +1,26 @@
+/**
+ * Minimal Next.js config for production runtime.
+ *
+ * next.config.ts is the authoritative config used at build time (Turbopack rules,
+ * transpilePackages, sassOptions, etc.).  However, Next.js 16 tries to transpile
+ * .ts configs at server startup, which fails in production where TypeScript is not
+ * installed.  assemble-prod.sh therefore deletes next.config.ts and renames this
+ * file to next.config.js so the production server can load the runtime-critical
+ * settings (i18n routing, pageExtensions, …) without a TypeScript toolchain.
+ *
+ * Keep the runtime-relevant values in sync with next.config.ts.
+ */
+
+const nextI18nConfig = require('./config/next-i18next.config');
+
+const { i18n } = nextI18nConfig;
+
+/** @type {import('next').NextConfig} */
+module.exports = {
+  reactStrictMode: true,
+  poweredByHeader: false,
+  pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
+  i18n,
+
+  serverExternalPackages: ['handsontable'],
+};

+ 56 - 59
apps/app/next.config.ts

@@ -6,7 +6,6 @@
  */
 
 import type { NextConfig } from 'next';
-import { PHASE_PRODUCTION_SERVER } from 'next/constants';
 import path from 'node:path';
 
 import nextI18nConfig from './config/next-i18next.config';
@@ -85,66 +84,64 @@ const optimizePackageImports: string[] = [
   '@growi/ui',
 ];
 
-export default (phase: string): NextConfig => {
-  /** @type {import('next').NextConfig} */
-  const nextConfig: NextConfig = {
-    reactStrictMode: true,
-    poweredByHeader: false,
-    pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
-    i18n,
+// This config is used at build time only (next build / next dev).
+// Production runtime uses next.config.prod.cjs (installed as next.config.js by assemble-prod.sh).
+const nextConfig: NextConfig = {
+  reactStrictMode: true,
+  poweredByHeader: false,
+  pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
+  i18n,
 
-    serverExternalPackages: [
-      'handsontable', // Legacy v6.2.2 requires @babel/polyfill which is unavailable; client-only via dynamic import
-    ],
+  serverExternalPackages: [
+    'handsontable', // Legacy v6.2.2 requires @babel/polyfill which is unavailable; client-only via dynamic import
+  ],
 
-    // for build
-    typescript: {
-      tsconfigPath: 'tsconfig.build.client.json',
-    },
-    transpilePackages:
-      phase !== PHASE_PRODUCTION_SERVER ? getTranspilePackages() : undefined,
-    sassOptions: {
-      loadPaths: [path.resolve(__dirname, 'src')],
-    },
-    experimental: {
-      optimizePackageImports,
-    },
+  // for build
+  typescript: {
+    tsconfigPath: 'tsconfig.build.client.json',
+  },
+  transpilePackages: getTranspilePackages(),
+  sassOptions: {
+    loadPaths: [path.resolve(__dirname, 'src')],
+  },
+  experimental: {
+    optimizePackageImports,
+  },
 
-    turbopack: {
-      rules: {
-        // Server-only: auto-wrap getServerSideProps with SuperJSON serialization
-        '*.page.ts': [
-          {
-            condition: { not: 'browser' },
-            loaders: [
-              path.resolve(__dirname, 'src/utils/superjson-ssr-loader.ts'),
-            ],
-            as: '*.ts',
-          },
-        ],
-        '*.page.tsx': [
-          {
-            condition: { not: 'browser' },
-            loaders: [
-              path.resolve(__dirname, 'src/utils/superjson-ssr-loader.ts'),
-            ],
-            as: '*.tsx',
-          },
-        ],
-      },
-      resolveAlias: {
-        // Exclude fs from client bundle
-        fs: { browser: './src/lib/empty-module.ts' },
-        // Exclude server-only packages from client bundle
-        'dtrace-provider': { browser: './src/lib/empty-module.ts' },
-        mongoose: { browser: './src/lib/empty-module.ts' },
-        'i18next-fs-backend': { browser: './src/lib/empty-module.ts' },
-        bunyan: { browser: './src/lib/empty-module.ts' },
-        'bunyan-format': { browser: './src/lib/empty-module.ts' },
-        'core-js': { browser: './src/lib/empty-module.ts' },
-      },
+  turbopack: {
+    rules: {
+      // Server-only: auto-wrap getServerSideProps with SuperJSON serialization
+      '*.page.ts': [
+        {
+          condition: { not: 'browser' },
+          loaders: [
+            path.resolve(__dirname, 'src/utils/superjson-ssr-loader.ts'),
+          ],
+          as: '*.ts',
+        },
+      ],
+      '*.page.tsx': [
+        {
+          condition: { not: 'browser' },
+          loaders: [
+            path.resolve(__dirname, 'src/utils/superjson-ssr-loader.ts'),
+          ],
+          as: '*.tsx',
+        },
+      ],
     },
-  };
-
-  return nextConfig;
+    resolveAlias: {
+      // Exclude fs from client bundle
+      fs: { browser: './src/lib/empty-module.ts' },
+      // Exclude server-only packages from client bundle
+      'dtrace-provider': { browser: './src/lib/empty-module.ts' },
+      mongoose: { browser: './src/lib/empty-module.ts' },
+      'i18next-fs-backend': { browser: './src/lib/empty-module.ts' },
+      bunyan: { browser: './src/lib/empty-module.ts' },
+      'bunyan-format': { browser: './src/lib/empty-module.ts' },
+      'core-js': { browser: './src/lib/empty-module.ts' },
+    },
+  },
 };
+
+export default nextConfig;

+ 43 - 24
apps/app/package.json

@@ -2,7 +2,7 @@
   "name": "@growi/app",
   "version": "7.5.0-RC.0",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "scripts": {
     "//// for production": "",
     "build": "run-p build:*",
@@ -10,7 +10,7 @@
     "build:client": "next build",
     "build:server": "cross-env NODE_ENV=production tspc -p tsconfig.build.server.json",
     "postbuild:server": "shx echo \"Listing files under transpiled\" && shx ls transpiled && shx rm -rf dist && shx mv transpiled/src dist && shx rm -rf transpiled",
-    "clean": "shx rm -rf dist transpiled .next",
+    "clean": "shx rm -rf dist transpiled .next next.config.js",
     "server": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "pnpm run server --ci",
     "preserver": "cross-env NODE_ENV=production pnpm run migrate",
@@ -70,6 +70,14 @@
     "@azure/openai": "^2.0.0",
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
+    "@codemirror/autocomplete": "^6.18.4",
+    "@codemirror/commands": "^6.8.0",
+    "@codemirror/lang-markdown": "^6.3.2",
+    "@codemirror/language": "^6.12.1",
+    "@codemirror/language-data": "^6.5.1",
+    "@codemirror/merge": "^6.8.0",
+    "@codemirror/state": "^6.5.2",
+    "@codemirror/view": "^6.39.14",
     "@cspell/dynamic-import": "^8.15.4",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.4",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.18.2",
@@ -77,6 +85,7 @@
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "workspace:^",
+    "@growi/emoji-mart-data": "workspace:^",
     "@growi/pdf-converter-client": "workspace:^",
     "@growi/pluginkit": "workspace:^",
     "@growi/presentation": "workspace:^",
@@ -87,7 +96,13 @@
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/slack": "workspace:^",
+    "@handsontable/react": "=2.1.0",
+    "@headless-tree/core": "^1.5.3",
+    "@headless-tree/react": "^1.5.3",
     "@keycloak/keycloak-admin-client": "^18.0.0",
+    "@lezer/highlight": "^1.2.3",
+    "@marp-team/marp-core": "^3.9.1",
+    "@marp-team/marpit": "^2.6.1",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/auto-instrumentations-node": "^0.60.1",
     "@opentelemetry/exporter-metrics-otlp-grpc": "^0.202.0",
@@ -97,8 +112,12 @@
     "@opentelemetry/sdk-node": "^0.202.0",
     "@opentelemetry/sdk-trace-node": "^2.0.1",
     "@opentelemetry/semantic-conventions": "^1.34.0",
+    "@replit/codemirror-emacs": "^6.1.0",
+    "@replit/codemirror-vim": "^6.2.1",
+    "@replit/codemirror-vscode-keymap": "^6.0.2",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
+    "@tanstack/react-virtual": "^3.13.12",
     "@types/async": "^3.2.24",
     "@types/multer": "^1.4.12",
     "JSONStream": "^1.3.5",
@@ -109,9 +128,12 @@
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
+    "bootstrap": "=5.3.2",
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
     "bunyan": "^1.8.15",
+    "cm6-theme-basic-light": "^0.2.0",
+    "codemirror": "^6.0.1",
     "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",
@@ -124,8 +146,10 @@
     "dayjs": "^1.11.7",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
+    "diff2html": "^3.4.47",
     "diff_match_patch": "^0.1.1",
     "dotenv-flow": "^3.2.0",
+    "downshift": "^8.2.3",
     "ejs": "^3.1.10",
     "escape-string-regexp": "^4.0.0",
     "expose-gc": "^1.0.0",
@@ -135,6 +159,7 @@
     "express-session": "^1.16.1",
     "express-validator": "^6.14.0",
     "extensible-custom-error": "^0.0.7",
+    "fastest-levenshtein": "^1.0.16",
     "form-data": "^4.0.4",
     "graceful-fs": "^4.1.11",
     "hast-util-sanitize": "^5.0.1",
@@ -143,6 +168,8 @@
     "helmet": "^4.6.0",
     "http-errors": "^2.0.0",
     "i18next": "^23.16.5",
+    "i18next-http-backend": "^2.6.2",
+    "i18next-localstorage-backend": "^4.2.0",
     "i18next-resources-to-backend": "^1.2.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
@@ -174,7 +201,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^16.0.0",
+    "next": "^16.1.7",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-themes": "^0.2.1",
@@ -185,6 +212,7 @@
     "openai": "^4.96.2",
     "openid-client": "^5.4.0",
     "p-retry": "^4.0.0",
+    "pako": "^2.1.0",
     "passport": "^0.6.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
@@ -192,22 +220,30 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "pathe": "^2.0.3",
+    "pretty-bytes": "^6.1.1",
     "prop-types": "^15.8.1",
     "qs": "^6.14.2",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",
     "react-card-flip": "^1.0.10",
+    "react-copy-to-clipboard": "^5.0.1",
     "react-datepicker": "^4.7.0",
     "react-disable": "^0.1.1",
+    "react-dnd": "^14.0.5",
+    "react-dnd-html5-backend": "^14.1.0",
     "react-dom": "^18.2.0",
+    "react-dropzone": "^14.2.3",
     "react-error-boundary": "^3.1.4",
+    "react-hook-form": "^7.45.4",
     "react-i18next": "^15.1.1",
     "react-image-crop": "^8.3.0",
+    "react-input-autosize": "^3.0.0",
     "react-markdown": "^9.0.1",
     "react-multiline-clamp": "^2.0.0",
     "react-stickynode": "^4.1.1",
     "react-syntax-highlighter": "^16.1.0",
+    "react-toastify": "^9.1.3",
     "react-use-ripple": "^1.5.2",
     "reactstrap": "^9.2.2",
     "reconnecting-websocket": "^4.4.0",
@@ -228,7 +264,9 @@
     "remark-parse": "^11.0.0",
     "remark-rehype": "^11.1.1",
     "remark-stringify": "^11.0.0",
+    "reveal.js": "^4.4.8",
     "sanitize-filename": "^1.6.3",
+    "simplebar-react": "^2.3.6",
     "socket.io": "^4.7.5",
     "string-width": "=4.2.2",
     "superjson": "^2.2.2",
@@ -250,6 +288,7 @@
     "validator": "^13.15.22",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
+    "y-codemirror.next": "^0.3.5",
     "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
     "yjs": "^13.6.18",
@@ -263,17 +302,11 @@
   },
   "devDependencies": {
     "@apidevtools/swagger-parser": "^10.1.1",
-    "@codemirror/state": "^6.5.2",
-    "@emoji-mart/data": "^1.2.1",
     "@growi/core-styles": "workspace:^",
     "@growi/custom-icons": "workspace:^",
     "@growi/editor": "workspace:^",
     "@growi/ui": "workspace:^",
-    "@handsontable/react": "=2.1.0",
-    "@headless-tree/core": "^1.5.3",
-    "@headless-tree/react": "^1.5.3",
     "@popperjs/core": "^2.11.8",
-    "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
@@ -298,19 +331,13 @@
     "@types/uuid": "^10.0.0",
     "@types/ws": "^8.18.1",
     "babel-loader": "^8.2.5",
-    "bootstrap": "=5.3.2",
     "commander": "^14.0.0",
     "connect-browser-sync": "^2.1.0",
-    "diff2html": "^3.4.47",
-    "downshift": "^8.2.3",
     "eazy-logger": "^3.1.0",
-    "fastest-levenshtein": "^1.0.16",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
     "happy-dom": "^15.7.4",
     "i18next-chained-backend": "^4.6.2",
-    "i18next-http-backend": "^2.6.2",
-    "i18next-localstorage-backend": "^4.2.0",
     "jotai-devtools": "^0.11.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
@@ -320,22 +347,14 @@
     "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "openapi-typescript": "^7.8.0",
-    "pretty-bytes": "^6.1.1",
-    "react-copy-to-clipboard": "^5.0.1",
-    "react-dnd": "^14.0.5",
-    "react-dnd-html5-backend": "^14.1.0",
-    "react-dropzone": "^14.2.3",
-    "react-hook-form": "^7.45.4",
-    "react-input-autosize": "^3.0.0",
-    "react-toastify": "^9.1.3",
     "rehype-rewrite": "^4.0.2",
     "remark-github-admonitions-to-directives": "^2.0.0",
     "sass": "^1.53.0",
-    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
     "tinykeys": "^3.0.0",
+    "typescript": "~5.0.4",
     "unist-util-is": "^6.0.0",
     "unist-util-visit-parents": "^6.0.0"
   }

+ 32 - 17
apps/app/src/client/components/ReactMarkdownComponents/LightBox.tsx

@@ -1,7 +1,10 @@
-import type React from 'react';
-import type { DetailedHTMLProps, ImgHTMLAttributes, JSX } from 'react';
-import { useMemo, useState } from 'react';
-import FsLightbox from 'fslightbox-react';
+import type {
+  ComponentType,
+  DetailedHTMLProps,
+  ImgHTMLAttributes,
+  JSX,
+} from 'react';
+import { useEffect, useState } from 'react';
 import { createPortal } from 'react-dom';
 
 type Props = DetailedHTMLProps<
@@ -9,22 +12,24 @@ type Props = DetailedHTMLProps<
   HTMLImageElement
 >;
 
+type FsLightboxProps = {
+  toggler: boolean;
+  sources: (string | undefined)[];
+  alt: string | undefined;
+  type: string;
+  exitFullscreenOnClose: boolean;
+};
+
 export const LightBox = (props: Props): JSX.Element => {
   const [toggler, setToggler] = useState(false);
+  // Dynamically import fslightbox-react so it stays out of the SSR bundle
+  const [FsLightbox, setFsLightbox] =
+    useState<ComponentType<FsLightboxProps> | null>(null);
   const { alt, ...rest } = props;
 
-  const lightboxPortal = useMemo(() => {
-    return createPortal(
-      <FsLightbox
-        toggler={toggler}
-        sources={[props.src]}
-        alt={alt}
-        type="image"
-        exitFullscreenOnClose
-      />,
-      document.body,
-    );
-  }, [alt, props.src, toggler]);
+  useEffect(() => {
+    import('fslightbox-react').then((m) => setFsLightbox(() => m.default));
+  }, []);
 
   return (
     <>
@@ -37,7 +42,17 @@ export const LightBox = (props: Props): JSX.Element => {
         <img alt={alt} {...rest} />
       </button>
 
-      {lightboxPortal}
+      {FsLightbox != null &&
+        createPortal(
+          <FsLightbox
+            toggler={toggler}
+            sources={[props.src]}
+            alt={alt}
+            type="image"
+            exitFullscreenOnClose
+          />,
+          document.body,
+        )}
     </>
   );
 };

+ 9 - 2
apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx

@@ -1,4 +1,5 @@
-import React, { type JSX, useState } from 'react';
+import { type JSX, useState } from 'react';
+import dynamic from 'next/dynamic';
 import type { IRevisionHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
@@ -10,7 +11,13 @@ import {
   DropdownToggle,
 } from 'reactstrap';
 
-import { RevisionDiff } from '../PageHistory/RevisionDiff';
+// diff2html is a large library used only for interactive diff viewing.
+// ssr: false skips server-side rendering for performance; the package itself must
+// stay in dependencies because Turbopack still externalises it via static import analysis.
+const RevisionDiff = dynamic(
+  () => import('../PageHistory/RevisionDiff').then((mod) => mod.RevisionDiff),
+  { ssr: false },
+);
 
 import styles from './RevisionComparer.module.scss';
 

+ 5 - 7
apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx

@@ -1,14 +1,14 @@
 import { type JSX, Suspense, useState } from 'react';
 import dynamic from 'next/dynamic';
-import { DndProvider } from 'react-dnd';
-import { HTML5Backend } from 'react-dnd-html5-backend';
 import { useTranslation } from 'react-i18next';
 
 import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
 import { PageTreeHeader } from './PageTreeSubstance';
 
-const PageTreeContent = dynamic(
-  () => import('./PageTreeSubstance').then((mod) => mod.PageTreeContent),
+// PageTreeWithDnD uses HTML5Backend which accesses browser APIs on mount;
+// ssr: false prevents it from rendering on the server.
+const PageTreeWithDnD = dynamic(
+  () => import('./PageTreeSubstance').then((mod) => mod.PageTreeWithDnD),
   { ssr: false, loading: ItemsTreeContentSkeleton },
 );
 
@@ -32,9 +32,7 @@ export const PageTree = (): JSX.Element => {
       </div>
 
       <Suspense fallback={<ItemsTreeContentSkeleton />}>
-        <DndProvider backend={HTML5Backend}>
-          <PageTreeContent isWipPageShown={isWipPageShown} />
-        </DndProvider>
+        <PageTreeWithDnD isWipPageShown={isWipPageShown} />
       </Suspense>
     </div>
   );

+ 60 - 57
apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -1,5 +1,7 @@
-import React, { memo, useCallback } from 'react';
+import { memo, useCallback, useId } from 'react';
 import { useTranslation } from 'next-i18next';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
 
 import { ItemsTree } from '~/features/page-tree/components';
 import { usePageTreeInformationUpdate } from '~/features/page-tree/states/page-tree-update';
@@ -12,14 +14,11 @@ import {
   useSWRxRootPage,
   useSWRxV5MigrationStatus,
 } from '~/stores/page-listing';
-import loggerFactory from '~/utils/logger';
 
 import { PageTreeItem, pageTreeItemSize } from '../PageTreeItem';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 
-const logger = loggerFactory('growi:cli:PageTreeSubstance');
-
 type HeaderProps = {
   isWipPageShown: boolean;
   onWipPageShownChange?: () => void;
@@ -28,6 +27,7 @@ type HeaderProps = {
 export const PageTreeHeader = memo(
   ({ isWipPageShown, onWipPageShownChange }: HeaderProps) => {
     const { t } = useTranslation();
+    const wipToggleId = useId();
 
     const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
     useSWRxV5MigrationStatus({ suspense: true });
@@ -66,7 +66,7 @@ export const PageTreeHeader = memo(
               >
                 <div className="form-check form-switch">
                   <input
-                    id="page-tree-wip-toggle"
+                    id={wipToggleId}
                     className="form-check-input pe-none"
                     type="checkbox"
                     checked={isWipPageShown}
@@ -74,7 +74,7 @@ export const PageTreeHeader = memo(
                   />
                   <label
                     className="form-check-label pe-none"
-                    htmlFor="page-tree-wip-toggle"
+                    htmlFor={wipToggleId}
                   >
                     {t('sidebar_header.show_wip_page')}
                   </label>
@@ -106,63 +106,66 @@ type PageTreeContentProps = {
   isWipPageShown: boolean;
 };
 
-export const PageTreeContent = memo(
-  ({ isWipPageShown }: PageTreeContentProps) => {
-    const isGuestUser = useIsGuestUser();
-    const isReadOnlyUser = useIsReadOnlyUser();
-    const currentPath = useCurrentPagePath();
-    const targetId = useCurrentPageId();
+const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) => {
+  const isGuestUser = useIsGuestUser();
+  const isReadOnlyUser = useIsReadOnlyUser();
+  const currentPath = useCurrentPagePath();
+  const targetId = useCurrentPageId();
 
-    const { data: migrationStatus } = useSWRxV5MigrationStatus({
-      suspense: true,
-    });
+  const { data: migrationStatus } = useSWRxV5MigrationStatus({
+    suspense: true,
+  });
 
-    const targetPathOrId = targetId || currentPath;
-    const path = currentPath || '/';
+  const targetPathOrId = targetId || currentPath;
+  const path = currentPath || '/';
 
-    const sidebarScrollerElem = useSidebarScrollerElem();
+  const sidebarScrollerElem = useSidebarScrollerElem();
 
-    const estimateTreeItemSize = useCallback(() => pageTreeItemSize, []);
+  const estimateTreeItemSize = useCallback(() => pageTreeItemSize, []);
 
-    if (!migrationStatus?.isV5Compatible) {
-      return <PageTreeUnavailable />;
-    }
+  if (!migrationStatus?.isV5Compatible) {
+    return <PageTreeUnavailable />;
+  }
 
-    /*
-     * dependencies
-     */
-    if (isGuestUser == null) {
-      return null;
-    }
+  /*
+   * dependencies
+   */
+  if (isGuestUser == null) {
+    return null;
+  }
 
-    return (
-      <div className="pt-4">
-        <ItemsTree
-          enableRenaming
-          enableDragAndDrop
-          isEnableActions={!isGuestUser}
-          isReadOnlyUser={!!isReadOnlyUser}
-          isWipPageShown={isWipPageShown}
-          targetPath={path}
-          targetPathOrId={targetPathOrId}
-          CustomTreeItem={PageTreeItem}
-          estimateTreeItemSize={estimateTreeItemSize}
-          scrollerElem={sidebarScrollerElem}
-        />
-
-        {!isGuestUser &&
-          !isReadOnlyUser &&
-          migrationStatus?.migratablePagesCount != null &&
-          migrationStatus.migratablePagesCount !== 0 && (
-            <div className="grw-pagetree-footer border-top mt-4 py-2 w-100">
-              <div className="private-legacy-pages-link px-3 py-2">
-                <PrivateLegacyPagesLink />
-              </div>
+  return (
+    <div className="pt-4">
+      <ItemsTree
+        enableRenaming
+        enableDragAndDrop
+        isEnableActions={!isGuestUser}
+        isReadOnlyUser={!!isReadOnlyUser}
+        isWipPageShown={isWipPageShown}
+        targetPath={path}
+        targetPathOrId={targetPathOrId}
+        CustomTreeItem={PageTreeItem}
+        estimateTreeItemSize={estimateTreeItemSize}
+        scrollerElem={sidebarScrollerElem}
+      />
+
+      {!isGuestUser &&
+        !isReadOnlyUser &&
+        migrationStatus?.migratablePagesCount != null &&
+        migrationStatus.migratablePagesCount !== 0 && (
+          <div className="grw-pagetree-footer border-top mt-4 py-2 w-100">
+            <div className="private-legacy-pages-link px-3 py-2">
+              <PrivateLegacyPagesLink />
             </div>
-          )}
-      </div>
-    );
-  },
-);
-
+          </div>
+        )}
+    </div>
+  );
+});
 PageTreeContent.displayName = 'PageTreeContent';
+
+export const PageTreeWithDnD = ({ isWipPageShown }: PageTreeContentProps) => (
+  <DndProvider backend={HTML5Backend}>
+    <PageTreeContent isWipPageShown={isWipPageShown} />
+  </DndProvider>
+);

+ 3 - 0
apps/app/src/components/Layout/AdminLayout.tsx

@@ -3,6 +3,7 @@ import dynamic from 'next/dynamic';
 import Link from 'next/link';
 
 import GrowiLogo from '~/components/Common/GrowiLogo';
+import { useSetupAdminSocket } from '~/features/admin/states/socket-io';
 
 import { RawLayout } from './RawLayout';
 
@@ -32,6 +33,8 @@ type Props = {
 };
 
 const AdminLayout = ({ children, componentTitle }: Props): JSX.Element => {
+  useSetupAdminSocket();
+
   return (
     <RawLayout>
       <div className={`admin-page ${styles['admin-page']}`}>

+ 35 - 19
apps/app/src/features/admin/states/socket-io.ts

@@ -1,31 +1,47 @@
-import { useAtomValue } from 'jotai';
-import { atomWithLazy } from 'jotai/utils';
+import { useEffect } from 'react';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
 import type { Socket } from 'socket.io-client';
-import io from 'socket.io-client';
 
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:cli:states:socket');
 
-const socketFactory = (namespace: string): Socket => {
-  const socket = io(namespace, {
-    transports: ['websocket'],
-  });
+// Socket.IO client is imported dynamically so that socket.io-client stays out
+// of the SSR bundle (.next/node_modules/) and can be listed in devDependencies.
+const adminSocketAtom = atom<Socket | null>(null);
 
-  socket.on('connect_error', (error) => {
-    logger.error(namespace, error);
-  });
-  socket.on('error', (error) => {
-    logger.error(namespace, error);
-  });
+/**
+ * Hook to initialise the admin Socket.IO connection.
+ * Call this once from AdminLayout so every admin page shares the connection.
+ */
+export const useSetupAdminSocket = (): void => {
+  const setSocket = useSetAtom(adminSocketAtom);
+  const socket = useAtomValue(adminSocketAtom);
 
-  return socket;
-};
+  useEffect(() => {
+    if (socket != null) return;
+
+    let cancelled = false;
 
-// Lazy atoms for socket instances (created only when accessed)
-const adminSocketAtom = atomWithLazy(() => socketFactory('/admin'));
+    import('socket.io-client')
+      .then(({ default: io }) => {
+        if (cancelled) return;
+        const newSocket = io('/admin', { transports: ['websocket'] });
+        newSocket.on('connect_error', (error) => logger.error('/admin', error));
+        newSocket.on('error', (error) => logger.error('/admin', error));
+        setSocket(newSocket);
+      })
+      .catch((error) =>
+        logger.error('Failed to initialize admin WebSocket:', error),
+      );
+
+    return () => {
+      cancelled = true;
+    };
+  }, [socket, setSocket]);
+};
 
-// Hooks for socket access
-export const useAdminSocket = (): Socket => {
+/** Returns the admin Socket.IO instance, or null before it is initialised. */
+export const useAdminSocket = (): Socket | null => {
   return useAtomValue(adminSocketAtom);
 };

+ 2 - 1
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts

@@ -174,7 +174,8 @@ export class GrowiPluginService implements IGrowiPluginService {
       reposName,
     );
 
-    if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
+    if (!fs.existsSync(organizationPath))
+      fs.mkdirSync(organizationPath, { recursive: true });
 
     let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
 

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

@@ -1062,7 +1062,7 @@ export const CONFIG_DEFINITIONS = {
     defaultValue: false,
   }),
   'customize:isEnabledMarp': defineConfig<boolean>({
-    defaultValue: false,
+    defaultValue: true,
   }),
   'customize:isSidebarCollapsedMode': defineConfig<boolean>({
     defaultValue: false,

+ 1 - 9
apps/app/src/server/util/project-dir-utils.ts

@@ -1,15 +1,7 @@
-import fs from 'node:fs';
 import path from 'node:path';
 import process from 'node:process';
-import { isServer } from '@growi/core/dist/utils/browser-utils';
 
-const isCurrentDirRoot =
-  isServer() &&
-  (fs.existsSync('./next.config.ts') || fs.existsSync('./next.config.js'));
-
-export const projectRoot = isCurrentDirRoot
-  ? process.cwd()
-  : path.resolve(__dirname, '../../');
+export const projectRoot = process.cwd();
 
 export function resolveFromRoot(relativePath: string): string {
   return path.resolve(projectRoot, relativePath);

+ 3 - 2
apps/app/src/services/renderer/remark-plugins/emoji.ts

@@ -1,4 +1,5 @@
-import emojiData from '@emoji-mart/data/sets/15/native.json';
+// Re-run `turbo run build --filter @growi/emoji-mart-data` whenever @emoji-mart/data is upgraded.
+import emojiNativeLookup from '@growi/emoji-mart-data';
 import type { Root } from 'mdast';
 import { findAndReplace } from 'mdast-util-find-and-replace';
 import type { Plugin } from 'unified';
@@ -10,7 +11,7 @@ export const remarkPlugin: Plugin = () => {
       /:(\+1|[-\w]+):/g,
 
       (_, $1: string) => {
-        const emoji = emojiData.emojis[$1]?.skins[0].native;
+        const emoji = emojiNativeLookup[$1]?.skins[0].native;
         return emoji ?? false;
       },
     ]);

+ 17 - 0
apps/app/test/setup/mongo/global-setup.ts

@@ -0,0 +1,17 @@
+import { MongoBinary } from 'mongodb-memory-server-core';
+
+import { MONGOMS_BINARY_OPTS } from './utils';
+
+/**
+ * Global setup: pre-download the MongoDB binary before any workers start.
+ * This prevents lock-file race conditions when multiple Vitest workers try to
+ * download the binary concurrently on the first run.
+ */
+export async function setup(): Promise<void> {
+  // Skip if using an external MongoDB (e.g. CI with GitHub Actions services)
+  if (process.env.MONGO_URI != null) {
+    return;
+  }
+
+  await MongoBinary.getPath(MONGOMS_BINARY_OPTS);
+}

+ 3 - 10
apps/app/test/setup/mongo/index.ts

@@ -4,7 +4,7 @@ import { afterAll, beforeAll } from 'vitest';
 
 import { mongoOptions } from '~/server/util/mongoose-utils';
 
-import { getTestDbConfig } from './utils';
+import { getTestDbConfig, MONGOMS_BINARY_OPTS } from './utils';
 
 let mongoServer: MongoMemoryServer | undefined;
 
@@ -27,18 +27,11 @@ beforeAll(async () => {
   }
 
   // Use MongoMemoryServer for local development
-  // set debug flag
   process.env.MONGOMS_DEBUG = process.env.VITE_MONGOMS_DEBUG;
 
-  // set version
   mongoServer = await MongoMemoryServer.create({
-    instance: {
-      dbName,
-    },
-    binary: {
-      version: process.env.VITE_MONGOMS_VERSION,
-      downloadDir: 'node_modules/.cache/mongodb-binaries',
-    },
+    instance: { dbName },
+    binary: MONGOMS_BINARY_OPTS,
   });
 
   // biome-ignore lint/suspicious/noConsole: Allow logging

+ 6 - 0
apps/app/test/setup/mongo/utils.ts

@@ -1,4 +1,10 @@
 import ConnectionString from 'mongodb-connection-string-url';
+import type { MongoBinary } from 'mongodb-memory-server-core';
+
+export const MONGOMS_BINARY_OPTS: Parameters<typeof MongoBinary.getPath>[0] = {
+  version: process.env.VITE_MONGOMS_VERSION,
+  downloadDir: 'node_modules/.cache/mongodb-binaries',
+};
 
 /**
  * Replace the database name in a MongoDB connection URI.

+ 1 - 4
apps/app/tsconfig.json

@@ -27,10 +27,7 @@
     ]
   },
   "include": ["next-env.d.ts", "config", "src"],
-  "exclude": [
-    "src/**/*.vendor-styles.ts",
-    "src/**/*.vendor-styles.prebuilt.js"
-  ],
+  "exclude": ["src/**/*.vendor-styles.*"],
   "ts-node": {
     "transpileOnly": true,
     "swc": true,

+ 5 - 5
apps/app/turbo.json

@@ -15,7 +15,7 @@
     },
     "pre:styles-components": {
       "dependsOn": ["^build"],
-      "outputs": ["src/**/*.vendor-styles.prebuilt.js"],
+      "outputs": ["src/**/*.vendor-styles.prebuilt.*"],
       "inputs": [
         "vite.vendor-styles-components.ts",
         "src/**/*.vendor-styles.ts",
@@ -27,7 +27,7 @@
       "dependsOn": ["^build", "pre:styles-commons", "pre:styles-components"],
       "outputs": [".next/**", "!.next/cache/**", "dist/**"],
       "inputs": [
-        "next.config.js",
+        "next.config.ts",
         "config/**",
         "public/**",
         "resource/**",
@@ -56,11 +56,11 @@
     },
     "dev:pre:styles-components": {
       "dependsOn": ["^dev"],
-      "outputs": ["src/**/*.vendor-styles.prebuilt.js"],
+      "outputs": ["src/**/*.vendor-styles.prebuilt.*"],
       "inputs": [
         "vite.vendor-styles-components.ts",
-        "src/**/*.vendor-styles.ts",
-        "!src/**/*.vendor-styles.prebuilt.js",
+        "src/**/*.vendor-styles.*",
+        "!src/**/*.vendor-styles.prebuilt.*",
         "package.json"
       ],
       "outputLogs": "new-only"

+ 4 - 4
apps/app/vite.vendor-styles-components.ts

@@ -18,7 +18,7 @@ const entries = fs
   );
 
 // Move emitted font assets from src/assets/ to public/static/fonts/
-// and rewrite URL references in prebuilt JS files
+// and rewrite URL references in prebuilt TS files
 function moveAssetsToPublic(): Plugin {
   return {
     name: 'move-assets-to-public',
@@ -34,8 +34,8 @@ function moveAssetsToPublic(): Plugin {
       }
       fs.rmdirSync(srcDir);
 
-      // Rewrite /assets/ -> /static/fonts/ and prepend // @ts-nocheck in prebuilt JS files
-      const prebuiltFiles = fs.globSync('src/**/*.vendor-styles.prebuilt.js', {
+      // Rewrite /assets/ -> /static/fonts/ and prepend // @ts-nocheck in prebuilt TS files
+      const prebuiltFiles = fs.globSync('src/**/*.vendor-styles.prebuilt.ts', {
         cwd: __dirname,
       });
       for (const file of prebuiltFiles) {
@@ -63,7 +63,7 @@ export default defineConfig({
       input: entries,
       output: {
         format: 'es',
-        entryFileNames: '[name].js',
+        entryFileNames: '[name].ts',
       },
     },
   },

+ 2 - 0
apps/app/vitest.workspace.mts

@@ -31,6 +31,8 @@ export default defineWorkspace([
       name: 'app-integration',
       environment: 'node',
       include: ['**/*.integ.ts'],
+      // Pre-download the MongoDB binary before workers start to avoid lock-file race conditions
+      globalSetup: ['./test/setup/mongo/global-setup.ts'],
       setupFiles: [
         './test/setup/migrate-mongo.ts',
         './test/setup/mongo/index.ts',

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

@@ -2,7 +2,7 @@
   "name": "@growi/slackbot-proxy",
   "version": "7.5.0-slackbot-proxy.0",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "scripts": {
     "build": "tspc -p tsconfig.build.json",
     "clean": "shx rm -rf dist",

+ 1 - 1
biome.json

@@ -15,12 +15,12 @@
       "!**/.devcontainer",
       "!**/.stylelintrc.json",
       "!**/package.json",
+      "!**/*.vendor-styles.prebuilt.*",
       "!.turbo",
       "!.vscode",
       "!.claude",
       "!tsconfig.base.json",
       "!apps/app/src/styles/prebuilt",
-      "!apps/app/src/**/*.vendor-styles.prebuilt.js",
       "!apps/app/next-env.d.ts",
       "!apps/app/tmp",
       "!apps/pdf-converter/specs",

+ 1 - 1
package.json

@@ -3,7 +3,7 @@
   "version": "7.5.0-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "tags": [
     "wiki",
     "communication",

+ 1 - 1
packages/custom-icons/package.json

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "Custom icons builder project for GROWI",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "scripts": {
     "build": "svgtofont --sources ./svg --output ./dist",
     "dev": "svgtofont --sources ./svg --output ./dist"

+ 2 - 1
packages/editor/package.json

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "A markdown editor for GROWI",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "type": "module",
   "main": "dist/index.js",
   "module": "dist/index.js",
@@ -39,6 +39,7 @@
     "@emoji-mart/data": "^1.2.1",
     "@emoji-mart/react": "^1.1.1",
     "@growi/core": "workspace:^",
+    "@growi/emoji-mart-data": "workspace:^",
     "@growi/core-styles": "workspace:^",
     "@lezer/highlight": "^1.2.0",
     "@popperjs/core": "^2.11.8",

+ 28 - 5
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/EmojiButton.tsx

@@ -1,11 +1,22 @@
-import { type CSSProperties, type JSX, useCallback, useState } from 'react';
-import emojiData from '@emoji-mart/data';
-import Picker from '@emoji-mart/react';
+import {
+  type ComponentType,
+  type CSSProperties,
+  type JSX,
+  useCallback,
+  useEffect,
+  useState,
+} from 'react';
 import { Modal } from 'reactstrap';
 
 import { useResolvedTheme } from '../../../../states/ui/resolved-theme';
 import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
 
+type PickerProps = {
+  onEmojiSelect: (emoji: { shortcodes: string }) => void;
+  theme: string | undefined;
+  data: unknown;
+};
+
 type Props = {
   editorKey: string;
 };
@@ -14,10 +25,22 @@ export const EmojiButton = (props: Props): JSX.Element => {
   const { editorKey } = props;
 
   const [isOpen, setIsOpen] = useState(false);
+  const [Picker, setPicker] = useState<ComponentType<PickerProps> | null>(null);
+  const [emojiData, setEmojiData] = useState<unknown>(null);
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
   const resolvedTheme = useResolvedTheme();
-  const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
+  const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
+
+  useEffect(() => {
+    if (!isOpen || Picker != null) return;
+    Promise.all([import('@emoji-mart/react'), import('@emoji-mart/data')]).then(
+      ([pickerMod, dataMod]) => {
+        setPicker(() => pickerMod.default as ComponentType<PickerProps>);
+        setEmojiData(dataMod.default);
+      },
+    );
+  }, [isOpen, Picker]);
 
   const selectEmoji = useCallback(
     (emoji: { shortcodes: string }): void => {
@@ -69,7 +92,7 @@ export const EmojiButton = (props: Props): JSX.Element => {
       <button type="button" className="btn btn-toolbar-button" onClick={toggle}>
         <span className="material-symbols-outlined fs-5">emoji_emotions</span>
       </button>
-      {isOpen && (
+      {isOpen && Picker != null && emojiData != null && (
         <div className="mb-2 d-none d-md-block">
           <Modal
             isOpen={isOpen}

+ 3 - 36
packages/editor/src/client/services-internal/extensions/emojiAutocompletionSettings.ts

@@ -4,42 +4,9 @@ import {
   type CompletionContext,
 } from '@codemirror/autocomplete';
 import { syntaxTree } from '@codemirror/language';
-import emojiData from '@emoji-mart/data';
+import nativeLookup from '@growi/emoji-mart-data';
 
-const getEmojiDataArray = (): string[] => {
-  const rawEmojiDataArray = emojiData.categories;
-
-  const emojiCategoriesData = [
-    'people',
-    'nature',
-    'foods',
-    'activity',
-    'places',
-    'objects',
-    'symbols',
-    'flags',
-  ];
-
-  const fixedEmojiDataArray: string[] = [];
-
-  emojiCategoriesData.forEach((value) => {
-    const tempArray = rawEmojiDataArray.find(
-      (obj: { id: string }) => obj.id === value,
-    )?.emojis;
-
-    if (tempArray == null) {
-      return;
-    }
-
-    fixedEmojiDataArray.push(...tempArray);
-  });
-
-  return fixedEmojiDataArray;
-};
-
-const emojiDataArray = getEmojiDataArray();
-
-const emojiOptions = emojiDataArray.map((tag) => ({
+const emojiOptions: Completion[] = Object.keys(nativeLookup).map((tag) => ({
   label: `:${tag}:`,
   type: tag,
 }));
@@ -66,7 +33,7 @@ export const emojiAutocompletionSettings = autocompletion({
     {
       render: (completion: Completion) => {
         const emojiName = completion.type ?? '';
-        const emoji = emojiData.emojis[emojiName].skins[0].native;
+        const emoji = nativeLookup[emojiName]?.skins[0].native ?? '';
 
         const element = document.createElement('span');
         element.innerHTML = emoji;

+ 9 - 0
packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts

@@ -1,4 +1,5 @@
 import { useMemo } from 'react';
+import { bracketMatching } from '@codemirror/language';
 import type { EditorState } from '@codemirror/state';
 import type { EditorView } from '@codemirror/view';
 import { type UseCodeMirror, useCodeMirror } from '@uiw/react-codemirror';
@@ -60,7 +61,15 @@ export const useCodeMirrorEditor = (
             dropCursor: false,
             highlightActiveLine: false,
             highlightActiveLineGutter: false,
+            // Disable default bracketMatching and re-add with afterCursor: false
+            // to prevent a rendering bug where text visually disappears after IME
+            // composition inside brackets on non-Safari browsers (e.g. Chrome).
+            // When afterCursor is true (default), bracketMatching decorates brackets
+            // ahead of the cursor immediately after composition ends, which corrupts
+            // CodeMirror's DOM reconciliation.
+            bracketMatching: false,
           },
+          extensions: [bracketMatching({ afterCursor: false })],
           // ------- End -------
         },
         props ?? {},

+ 3 - 3
packages/editor/turbo.json

@@ -3,17 +3,17 @@
   "extends": ["//"],
   "tasks": {
     "build": {
-      "dependsOn": ["@growi/core#build"],
+      "dependsOn": ["@growi/core#build", "@growi/emoji-mart-data#build"],
       "outputs": ["dist/**"],
       "outputLogs": "new-only"
     },
     "dev": {
-      "dependsOn": ["@growi/core#dev"],
+      "dependsOn": ["@growi/core#dev", "@growi/emoji-mart-data#build"],
       "outputs": ["dist/**"],
       "outputLogs": "new-only"
     },
     "lint": {
-      "dependsOn": ["@growi/core#dev"]
+      "dependsOn": ["@growi/core#dev", "@growi/emoji-mart-data#build"]
     },
     "serve": {
       "dependsOn": ["@growi/core#dev"],

+ 1 - 0
packages/emoji-mart-data/.gitignore

@@ -0,0 +1 @@
+/dist

+ 95 - 0
packages/emoji-mart-data/AGENTS.md

@@ -0,0 +1,95 @@
+# @growi/emoji-mart-data
+
+A build-time extraction package that produces a minimal emoji native lookup JSON
+from `@emoji-mart/data`. The generated artifact is consumed by two places in the
+monorepo:
+
+| Consumer | Usage |
+|---|---|
+| `apps/app` — `emoji.ts` (remark plugin) | Converts `:shortcode:` → native emoji during server-side Markdown rendering |
+| `packages/editor` — `emojiAutocompletionSettings.ts` | Populates CodeMirror autocomplete suggestions with native emoji previews |
+
+---
+
+## Why this package was created
+
+### The problem: Turbopack externalisation
+
+After migrating from webpack to Turbopack, packages that are statically imported
+in SSR-reachable code are **externalised** — Turbopack creates runtime symlinks
+under `.next/node_modules/` instead of inlining them. Any externalised package
+must be listed under `dependencies` (not `devDependencies`) in `apps/app/package.json`,
+otherwise `pnpm deploy --prod` produces a broken production artifact.
+
+`@emoji-mart/data` (~4 MB JSON) was statically imported in `apps/app/emoji.ts` and
+in `packages/editor/emojiAutocompletionSettings.ts`. Both import paths were
+transitively reachable from SSR, so Turbopack externalised the package and it
+had to live in `apps/app` `dependencies`.
+
+### The fix: break the static import chain
+
+The consumers only need a `name → native-emoji` mapping, a tiny subset of the
+full `@emoji-mart/data` payload. By extracting that mapping at build time into a
+plain JSON file and publishing it as `@growi/emoji-mart-data`, the static imports
+of `@emoji-mart/data` are eliminated:
+
+- **`emoji.ts`** and **`emojiAutocompletionSettings.ts`** now import
+  `@growi/emoji-mart-data` (a JSON, bundled inline by Turbopack — never externalised).
+- **`EmojiButton.tsx`** still needs the full `@emoji-mart/data` for the
+  `<Picker>` component, but loads it via `import()` inside a `useEffect` (the
+  only pattern confirmed to prevent Turbopack externalisation).
+
+Result: `@emoji-mart/data` and `@emoji-mart/react` are removed from
+`apps/app` `dependencies` entirely and remain only in `packages/editor`
+`devDependencies`.
+
+---
+
+## Why one output file is sufficient
+
+During design we initially expected to need **two** output files:
+
+- A *full* lookup for `apps/app` (all emoji, any order)
+- A *category-ordered* lookup for `packages/editor` autocomplete (8 categories,
+  UX-friendly order)
+
+Investigation of `@emoji-mart/data/sets/15/native.json` showed that the dataset
+has **exactly 8 categories** and all 1870 emojis fall into them:
+
+```
+people   529   nature  152   foods    133   activity  85
+places   218   objects 261   symbols  223   flags     269
+total: 1870  =  Object.keys(emojis).length
+```
+
+The `component` category (skin-tone modifier entries that could inflate the
+count) does not exist as a standalone category — skin tones are embedded inside
+each emoji's `skins[]` array and are not addressable by `:shortcode:`.
+
+Therefore one category-ordered file satisfies both consumers:
+
+- `apps/app/emoji.ts` does key lookups (`lookup[name]`) — order is irrelevant.
+- `packages/editor/emojiAutocompletionSettings.ts` uses `Object.keys(lookup)` —
+  category order means common emojis surface first in autocomplete.
+
+---
+
+## Maintenance
+
+Whenever `@emoji-mart/data` is upgraded, regenerate the lookup:
+
+```bash
+turbo run build --filter @growi/emoji-mart-data
+```
+
+Or directly from this package directory:
+
+```bash
+node bin/extract.ts
+```
+
+Commit the updated `pnpm-lock.yaml` and re-verify with:
+
+```bash
+turbo run build --filter @growi/app
+```

+ 1 - 0
packages/emoji-mart-data/CLAUDE.md

@@ -0,0 +1 @@
+@AGENTS.md

+ 84 - 0
packages/emoji-mart-data/bin/extract.ts

@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+/** biome-ignore-all lint/suspicious/noConsole: script output */
+/**
+ * Extracts a minimal emoji native lookup from @emoji-mart/data.
+ *
+ * Emojis are ordered by category so that CodeMirror autocomplete suggests
+ * common emojis (people, nature, …) before flags and symbols.
+ *
+ * Run via the package build script (from the monorepo root):
+ *   turbo run build --filter @growi/emoji-mart-data
+ *
+ * Or directly (from packages/emoji-mart-data/):
+ *   node bin/extract.ts
+ *
+ * Output: dist/index.js, dist/index.d.ts
+ * Re-run whenever @emoji-mart/data is upgraded.
+ */
+
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+type EmojiEntry = { skins: { native: string }[] };
+
+type EmojiData = {
+  categories: { id: string; emojis: string[] }[];
+  emojis: Record<string, EmojiEntry>;
+};
+
+type NativeLookup = Record<string, { skins: [{ native: string }] }>;
+
+const EMOJI_CATEGORIES = [
+  'people',
+  'nature',
+  'foods',
+  'activity',
+  'places',
+  'objects',
+  'symbols',
+  'flags',
+] as const;
+
+const inputPath = resolve(
+  import.meta.dirname,
+  '../node_modules/@emoji-mart/data/sets/15/native.json',
+);
+
+const raw: EmojiData = JSON.parse(readFileSync(inputPath, 'utf8'));
+
+// Build lookup in category order so consumers get UX-friendly suggestion order.
+const lookup: NativeLookup = {};
+for (const catId of EMOJI_CATEGORIES) {
+  const cat = raw.categories.find((c) => c.id === catId);
+  if (!cat) continue;
+  for (const name of cat.emojis) {
+    const native = raw.emojis[name]?.skins?.[0]?.native;
+    if (native) lookup[name] = { skins: [{ native }] };
+  }
+}
+
+const distDir = resolve(import.meta.dirname, '../dist');
+mkdirSync(distDir, { recursive: true });
+
+// Emit as an ES module so TypeScript resolves index.d.ts for types
+// instead of inferring a 1870-key literal type from the raw JSON.
+const jsPath = resolve(distDir, 'index.js');
+writeFileSync(
+  jsPath,
+  `// Generated — do not edit. Run \`node bin/extract.ts\` to regenerate.\nexport default ${JSON.stringify(lookup)};\n`,
+  'utf8',
+);
+console.log(`Wrote ${Object.keys(lookup).length} entries to ${jsPath}`);
+
+const dtsPath = resolve(distDir, 'index.d.ts');
+writeFileSync(
+  dtsPath,
+  [
+    'export type NativeLookup = Record<string, { skins: [{ native: string }] }>;',
+    'declare const _default: NativeLookup;',
+    'export default _default;',
+    '',
+  ].join('\n'),
+  'utf8',
+);
+console.log(`Wrote ${dtsPath}`);

+ 24 - 0
packages/emoji-mart-data/package.json

@@ -0,0 +1,24 @@
+{
+  "name": "@growi/emoji-mart-data",
+  "version": "1.0.0",
+  "description": "Extracted emoji-mart native lookup data for GROWI",
+  "license": "MIT",
+  "private": true,
+  "type": "module",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "default": "./dist/index.js"
+    }
+  },
+  "scripts": {
+    "build": "node bin/extract.ts",
+    "dev": "node bin/extract.ts",
+    "lint": "biome check"
+  },
+  "devDependencies": {
+    "@emoji-mart/data": "^1.2.1"
+  }
+}

+ 16 - 0
packages/emoji-mart-data/turbo.json

@@ -0,0 +1,16 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "tasks": {
+    "build": {
+      "outputs": ["dist/**"],
+      "inputs": ["bin/**", "package.json"],
+      "outputLogs": "new-only"
+    },
+    "dev": {
+      "outputs": ["dist/**"],
+      "inputs": ["bin/**", "package.json"],
+      "outputLogs": "new-only"
+    }
+  }
+}

+ 2 - 0
packages/presentation/.gitignore

@@ -1 +1,3 @@
 /dist
+# Generated at build time by scripts/extract-marpit-css.ts
+*.vendor-styles.prebuilt.*

+ 2 - 2
packages/presentation/package.json

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "A package for GROWI presentation feature",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "keywords": [
     "growi",
     "growi-plugin"
@@ -29,8 +29,8 @@
     }
   },
   "scripts": {
+    "generate:marpit-base-css": "node scripts/extract-marpit-css.ts",
     "build": "vite build",
-    "build:vendor-styles": "vite build --config vite.vendor-styles.ts",
     "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "pnpm run dev -w --emptyOutDir=false",

+ 70 - 0
packages/presentation/scripts/extract-marpit-css.ts

@@ -0,0 +1,70 @@
+/**
+ * Build-time script to extract Marp base CSS constants.
+ *
+ * Replicates the Marp configuration from growi-marpit.ts and generates
+ * pre-extracted CSS so that GrowiSlides can apply Marp container styling
+ * without a runtime dependency on @marp-team/marp-core or @marp-team/marpit.
+ *
+ * Regenerate with: node scripts/extract-marpit-css.ts
+ */
+
+import { writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { MarpOptions } from '@marp-team/marp-core';
+import { Marp } from '@marp-team/marp-core';
+import { Element } from '@marp-team/marpit';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+const MARP_CONTAINER_CLASS_NAME = 'marpit';
+
+const marpitOption: MarpOptions = {
+  container: [
+    new Element('div', { class: `slides ${MARP_CONTAINER_CLASS_NAME}` }),
+  ],
+  inlineSVG: true,
+  emoji: undefined,
+  html: false,
+  math: false,
+};
+
+// Slide mode: with shadow/rounded slide containers
+const slideMarpitOption: MarpOptions = { ...marpitOption };
+slideMarpitOption.slideContainer = [
+  new Element('section', { class: 'shadow rounded m-2' }),
+];
+const slideMarpit = new Marp(slideMarpitOption);
+
+// Presentation mode: minimal slide containers
+const presentationMarpitOption: MarpOptions = { ...marpitOption };
+presentationMarpitOption.slideContainer = [
+  new Element('section', { class: 'm-2' }),
+];
+const presentationMarpit = new Marp(presentationMarpitOption);
+
+const { css: slideCss } = slideMarpit.render('');
+const { css: presentationCss } = presentationMarpit.render('');
+
+if (!slideCss || !presentationCss) {
+  // biome-ignore lint/suspicious/noConsole: Allows console output for script
+  console.error('ERROR: CSS extraction produced empty output');
+  process.exit(1);
+}
+
+const output = `// Generated file — do not edit manually
+// Regenerate with: node scripts/extract-marpit-css.ts
+
+export const SLIDE_MARPIT_CSS = ${JSON.stringify(slideCss)};
+
+export const PRESENTATION_MARPIT_CSS = ${JSON.stringify(presentationCss)};
+`;
+
+const outPath = resolve(
+  __dirname,
+  '../src/client/consts/marpit-base-css.vendor-styles.prebuilt.ts',
+);
+writeFileSync(outPath, output, 'utf-8');
+
+// biome-ignore lint/suspicious/noConsole: Allows console output for script
+console.log(`Extracted Marp base CSS to ${outPath}`);

+ 5 - 7
packages/presentation/src/client/components/GrowiSlides.tsx

@@ -2,12 +2,11 @@ import type { JSX } from 'react';
 import Head from 'next/head';
 import ReactMarkdown from 'react-markdown';
 
-import type { PresentationOptions } from '../consts';
+import { MARP_CONTAINER_CLASS_NAME, type PresentationOptions } from '../consts';
 import {
-  MARP_CONTAINER_CLASS_NAME,
-  presentationMarpit,
-  slideMarpit,
-} from '../services/growi-marpit';
+  PRESENTATION_MARPIT_CSS,
+  SLIDE_MARPIT_CSS,
+} from '../consts/marpit-base-css.vendor-styles.prebuilt';
 import * as extractSections from '../services/renderer/extract-sections';
 import {
   PresentationRichSlideSection,
@@ -43,8 +42,7 @@ export const GrowiSlides = (props: Props): JSX.Element => {
     ? PresentationRichSlideSection
     : RichSlideSection;
 
-  const marpit = presentation ? presentationMarpit : slideMarpit;
-  const { css } = marpit.render('');
+  const css = presentation ? PRESENTATION_MARPIT_CSS : SLIDE_MARPIT_CSS;
   return (
     <>
       <Head>

+ 17 - 3
packages/presentation/src/client/components/Slides.tsx

@@ -1,11 +1,14 @@
-import type { JSX } from 'react';
+import { type JSX, lazy, Suspense } from 'react';
 
 import type { PresentationOptions } from '../consts';
 import { GrowiSlides } from './GrowiSlides';
-import { MarpSlides } from './MarpSlides';
 
 import styles from './Slides.module.scss';
 
+const MarpSlides = lazy(() =>
+  import('./MarpSlides').then((mod) => ({ default: mod.MarpSlides })),
+);
+
 export type SlidesProps = {
   options: PresentationOptions;
   children?: string;
@@ -19,7 +22,18 @@ export const Slides = (props: SlidesProps): JSX.Element => {
   return (
     <div className={`${styles['slides-styles']}`}>
       {hasMarpFlag ? (
-        <MarpSlides presentation={presentation}>{children}</MarpSlides>
+        <Suspense
+          fallback={
+            <div className="d-flex flex-column justify-content-center align-items-center py-5">
+              <output className="spinner-border text-secondary">
+                <span className="visually-hidden">Loading...</span>
+              </output>
+              <span className="mt-3 small text-secondary">Loading Marp...</span>
+            </div>
+          }
+        >
+          <MarpSlides presentation={presentation}>{children}</MarpSlides>
+        </Suspense>
       ) : (
         <GrowiSlides options={options} presentation={presentation}>
           {children}

+ 2 - 0
packages/presentation/src/client/consts/index.ts

@@ -1,6 +1,8 @@
 import type { Options as ReactMarkdownOptions } from 'react-markdown';
 import type { Options as RevealOptions } from 'reveal.js';
 
+export const MARP_CONTAINER_CLASS_NAME = 'marpit';
+
 export type PresentationOptions = {
   rendererOptions: ReactMarkdownOptions;
   revealOptions?: RevealOptions;

+ 4 - 1
packages/presentation/src/client/services/growi-marpit.ts

@@ -2,7 +2,9 @@ import type { MarpOptions } from '@marp-team/marp-core';
 import { Marp } from '@marp-team/marp-core';
 import { Element } from '@marp-team/marpit';
 
-export const MARP_CONTAINER_CLASS_NAME = 'marpit';
+import { MARP_CONTAINER_CLASS_NAME } from '../consts';
+
+export { MARP_CONTAINER_CLASS_NAME };
 
 // Add data-line to Marp slide.
 // https://github.com/marp-team/marp-vscode/blob/d9af184ed12b65bb28c0f328e250955d548ac1d1/src/plugins/line-number.ts
@@ -12,6 +14,7 @@ const lineNumber = (md) => {
     md.renderer.rules;
 
   // Enable line sync by per slides
+  // biome-ignore lint/nursery/useMaxParams: Allows 5 parameters for marpit renderer rules
   md.renderer.rules.marpit_slide_containers_open = (tks, i, opts, env, slf) => {
     const slide = tks.slice(i + 1).find((t) => t.type === 'marpit_slide_open');
 

+ 27 - 0
packages/presentation/turbo.json

@@ -0,0 +1,27 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "tasks": {
+    "generate:marpit-base-css": {
+      "inputs": [
+        "scripts/extract-marpit-css.ts",
+        "package.json"
+      ],
+      "outputs": ["src/client/consts/marpit-base-css.vendor-styles.prebuilt.ts"],
+      "outputLogs": "new-only"
+    },
+    "build": {
+      "dependsOn": ["generate:marpit-base-css"],
+      "outputs": ["dist/**"],
+      "outputLogs": "new-only"
+    },
+    "dev": {
+      "dependsOn": ["generate:marpit-base-css"],
+      "outputs": ["dist/**"],
+      "outputLogs": "new-only"
+    },
+    "lint": {
+      "dependsOn": ["generate:marpit-base-css"]
+    }
+  }
+}

+ 1 - 1
packages/preset-templates/package.json

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "GROWI preset templates",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "scripts": {
     "test": "vitest run",
     "lint": "biome check"

+ 1 - 1
packages/preset-themes/package.json

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "GROWI preset themes",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "main": "dist/libs/preset-themes.umd.js",
   "module": "dist/libs/preset-themes.mjs",
   "types": "dist/libs/index.d.ts",

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

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "Remark plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "keywords": [
     "growi",
     "growi-plugin"

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

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "Remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "keywords": [
     "unified",
     "remark",

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

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "Remark plugin to list pages",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "keywords": [
     "growi",
     "growi-plugin"

+ 1 - 1
packages/slack/package.json

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "Slack integration libraries for GROWI",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "type": "module",
   "main": "dist/index.cjs",
   "module": "dist/index.js",

+ 1 - 1
packages/ui/package.json

@@ -3,7 +3,7 @@
   "version": "1.0.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
-  "private": "true",
+  "private": true,
   "keywords": [
     "growi"
   ],

File diff suppressed because it is too large
+ 318 - 103
pnpm-lock.yaml


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