Parcourir la source

Merge remote-tracking branch 'origin/master' into feat/178240-suggest-path-spec

Yuki Takei il y a 2 semaines
Parent
commit
f0d68feb45
28 fichiers modifiés avec 1149 ajouts et 161 suppressions
  1. 324 0
      .kiro/specs/optimize-presentation/design.md
  2. 58 0
      .kiro/specs/optimize-presentation/requirements.md
  3. 84 0
      .kiro/specs/optimize-presentation/research.md
  4. 22 0
      .kiro/specs/optimize-presentation/spec.json
  5. 49 0
      .kiro/specs/optimize-presentation/tasks.md
  6. 5 5
      apps/app/.claude/skills/vendor-styles-components/SKILL.md
  7. 1 1
      apps/app/.gitignore
  8. 2 3
      apps/app/package.json
  9. 28 28
      apps/app/src/client/components/ContentLinkButtons.tsx
  10. 4 4
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  11. 1 1
      apps/app/src/client/components/StaffCredit/StaffCredit.module.scss
  12. 64 14
      apps/app/src/client/components/StaffCredit/StaffCredit.tsx
  13. 38 0
      apps/app/src/client/util/smooth-scroll.ts
  14. 2 5
      apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx
  15. 1 1
      apps/app/src/server/service/config-manager/config-definition.ts
  16. 1 4
      apps/app/tsconfig.json
  17. 4 4
      apps/app/turbo.json
  18. 4 4
      apps/app/vite.vendor-styles-components.ts
  19. 1 1
      biome.json
  20. 2 0
      packages/presentation/.gitignore
  21. 1 1
      packages/presentation/package.json
  22. 70 0
      packages/presentation/scripts/extract-marpit-css.ts
  23. 5 7
      packages/presentation/src/client/components/GrowiSlides.tsx
  24. 17 3
      packages/presentation/src/client/components/Slides.tsx
  25. 2 0
      packages/presentation/src/client/consts/index.ts
  26. 4 1
      packages/presentation/src/client/services/growi-marpit.ts
  27. 27 0
      packages/presentation/turbo.json
  28. 328 74
      pnpm-lock.yaml

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

+ 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

+ 1 - 1
apps/app/.gitignore

@@ -16,7 +16,7 @@ next.config.js
 /public/static/styles
 /public/uploads
 /src/styles/prebuilt
-/src/**/*.vendor-styles.prebuilt.js
+/src/**/*.vendor-styles.prebuilt.*
 /tmp/
 
 # cache

+ 2 - 3
apps/app/package.json

@@ -197,10 +197,11 @@
     "mongoose-gridfs": "^1.3.0",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
+    "motion": "^12.35.0",
     "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",
@@ -240,7 +241,6 @@
     "react-input-autosize": "^3.0.0",
     "react-markdown": "^9.0.1",
     "react-multiline-clamp": "^2.0.0",
-    "react-scroll": "^1.8.7",
     "react-stickynode": "^4.1.1",
     "react-syntax-highlighter": "^16.1.0",
     "react-toastify": "^9.1.3",
@@ -321,7 +321,6 @@
     "@types/react": "^18.2.14",
     "@types/react-dom": "^18.2.6",
     "@types/react-input-autosize": "^2.2.4",
-    "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
     "@types/supertest": "^6.0.3",
     "@types/testing-library__dom": "^7.5.0",

+ 28 - 28
apps/app/src/client/components/ContentLinkButtons.tsx

@@ -1,7 +1,8 @@
 import React, { type JSX } from 'react';
 import { type IUserHasId, USER_STATUS } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import { Link as ScrollLink } from 'react-scroll';
+
+import { scrollToElement } from '~/client/util/smooth-scroll';
 
 import {
   BOOKMARKS_LIST_ID,
@@ -12,15 +13,14 @@ import {
 const BookMarkLinkButton = React.memo(() => {
   const { t } = useTranslation();
   return (
-    <ScrollLink to={BOOKMARKS_LIST_ID} offset={-120}>
-      <button
-        type="button"
-        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
-      >
-        <span className="material-symbols-outlined p-0 me-2">bookmark</span>
-        <span>{t('user_home_page.bookmarks')}</span>
-      </button>
-    </ScrollLink>
+    <button
+      type="button"
+      className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
+      onClick={() => scrollToElement(BOOKMARKS_LIST_ID, { offset: -120 })}
+    >
+      <span className="material-symbols-outlined p-0 me-2">bookmark</span>
+      <span>{t('user_home_page.bookmarks')}</span>
+    </button>
   );
 });
 
@@ -29,15 +29,16 @@ BookMarkLinkButton.displayName = 'BookMarkLinkButton';
 const RecentlyCreatedLinkButton = React.memo(() => {
   const { t } = useTranslation();
   return (
-    <ScrollLink to={RECENTLY_CREATED_LIST_ID} offset={-120}>
-      <button
-        type="button"
-        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
-      >
-        <span className="growi-custom-icons mx-2 ">recently_created</span>
-        <span>{t('user_home_page.recently_created')}</span>
-      </button>
-    </ScrollLink>
+    <button
+      type="button"
+      className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
+      onClick={() =>
+        scrollToElement(RECENTLY_CREATED_LIST_ID, { offset: -120 })
+      }
+    >
+      <span className="growi-custom-icons mx-2 ">recently_created</span>
+      <span>{t('user_home_page.recently_created')}</span>
+    </button>
   );
 });
 
@@ -46,15 +47,14 @@ RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 const RecentActivityLinkButton = React.memo(() => {
   const { t } = useTranslation();
   return (
-    <ScrollLink to={RECENT_ACTIVITY_LIST_ID} offset={-120}>
-      <button
-        type="button"
-        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
-      >
-        <span className="material-symbols-outlined mx-1">update</span>
-        <span>{t('user_home_page.recent_activity')}</span>
-      </button>
-    </ScrollLink>
+    <button
+      type="button"
+      className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
+      onClick={() => scrollToElement(RECENT_ACTIVITY_LIST_ID, { offset: -120 })}
+    >
+      <span className="material-symbols-outlined mx-1">update</span>
+      <span>{t('user_home_page.recent_activity')}</span>
+    </button>
   );
 });
 

+ 4 - 4
apps/app/src/client/components/PageSideContents/PageSideContents.tsx

@@ -1,12 +1,12 @@
-import React, { type JSX, Suspense, useCallback, useRef } from 'react';
+import { type JSX, Suspense, useCallback, useRef } from 'react';
 import dynamic from 'next/dynamic';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { isIPageInfoForOperation } from '@growi/core/dist/interfaces';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import { scroller } from 'react-scroll';
 
+import { scrollToElement } from '~/client/util/smooth-scroll';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 import { showPageSideAuthorsAtom } from '~/states/server-configurations';
 import { useDescendantsPageListModalActions } from '~/states/ui/modal/descendants-page-list';
@@ -158,9 +158,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
                   : undefined
               }
               onClick={() =>
-                scroller.scrollTo('comments-container', {
-                  smooth: false,
+                scrollToElement('comments-container', {
                   offset: -120,
+                  duration: 0.3,
                 })
               }
             />

+ 1 - 1
apps/app/src/client/components/StaffCredit/StaffCredit.module.scss

@@ -63,6 +63,6 @@
   }
 
   :global(.staff-credit-content) {
-    padding-bottom: 40vh;
+    padding-bottom: 35vh !important;
   }
 }

+ 64 - 14
apps/app/src/client/components/StaffCredit/StaffCredit.tsx

@@ -1,6 +1,13 @@
-import React, { type JSX, useCallback, useState } from 'react';
+import React, {
+  type JSX,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
 import localFont from 'next/font/local';
-import { animateScroll } from 'react-scroll';
+import type { AnimationPlaybackControls } from 'motion';
+import { animate } from 'motion';
 import { Modal, ModalBody } from 'reactstrap';
 
 import { useSWRxStaffs } from '~/stores/staff';
@@ -10,6 +17,9 @@ import styles from './StaffCredit.module.scss';
 
 const _logger = loggerFactory('growi:components:StaffCredit');
 
+const SCROLL_DELAY = 200; // ms
+const SCROLL_SPEED = 300; // pixels per second
+
 // define fonts
 const pressStart2P = localFont({
   src: '../../../../resource/fonts/PressStart2P-latin.woff2',
@@ -27,6 +37,41 @@ const StaffCredit = (props: Props): JSX.Element => {
   const { data: contributors } = useSWRxStaffs();
 
   const [isScrolling, setScrolling] = useState(false);
+  const animationRef = useRef<AnimationPlaybackControls | null>(null);
+
+  const stopAutoScroll = useCallback(() => {
+    animationRef.current?.stop();
+    animationRef.current = null;
+    setScrolling(false);
+  }, []);
+
+  // Stop auto-scroll on wheel or scrollbar interaction
+  useEffect(() => {
+    if (!isScrolling) return;
+
+    const modalBody = document.getElementById('modalBody');
+    if (modalBody == null) return;
+
+    const handleWheel = () => {
+      stopAutoScroll();
+    };
+
+    const handlePointerDown = (event: PointerEvent) => {
+      const scrollbarStart =
+        modalBody.getBoundingClientRect().left + modalBody.clientWidth;
+      if (event.clientX >= scrollbarStart) {
+        stopAutoScroll();
+      }
+    };
+
+    modalBody.addEventListener('wheel', handleWheel, { passive: true });
+    modalBody.addEventListener('pointerdown', handlePointerDown);
+
+    return () => {
+      modalBody.removeEventListener('wheel', handleWheel);
+      modalBody.removeEventListener('pointerdown', handlePointerDown);
+    };
+  }, [isScrolling, stopAutoScroll]);
 
   const closeHandler = useCallback(() => {
     if (onClosed != null) {
@@ -36,11 +81,11 @@ const StaffCredit = (props: Props): JSX.Element => {
 
   const contentsClickedHandler = useCallback(() => {
     if (isScrolling) {
-      setScrolling(false);
+      stopAutoScroll();
     } else {
       closeHandler();
     }
-  }, [closeHandler, isScrolling]);
+  }, [closeHandler, isScrolling, stopAutoScroll]);
 
   const renderMembers = useCallback((memberGroup, keyPrefix) => {
     // construct members elements
@@ -113,19 +158,24 @@ const StaffCredit = (props: Props): JSX.Element => {
   }, [contentsClickedHandler, contributors, renderMembers]);
 
   const openedHandler = useCallback(() => {
-    // init
-    animateScroll.scrollTo(0, { containerId: 'modalBody', duration: 0 });
+    const container = document.getElementById('modalBody');
+    if (container == null) return;
 
+    container.scrollTop = 0;
     setScrolling(true);
 
-    // start scrolling
-    animateScroll.scrollToBottom({
-      containerId: 'modalBody',
-      smooth: 'linear',
-      delay: 200,
-      duration: (scrollDistanceInPx: number) => {
-        const scrollSpeed = 200;
-        return (scrollDistanceInPx / scrollSpeed) * 1000;
+    const maxScroll = container.scrollHeight - container.clientHeight;
+
+    animationRef.current = animate(0, maxScroll, {
+      duration: maxScroll / SCROLL_SPEED,
+      ease: 'linear',
+      delay: SCROLL_DELAY / 1000,
+      onUpdate: (v) => {
+        container.scrollTop = v;
+      },
+      onComplete: () => {
+        animationRef.current = null;
+        setScrolling(false);
       },
     });
   }, []);

+ 38 - 0
apps/app/src/client/util/smooth-scroll.ts

@@ -0,0 +1,38 @@
+import { animate } from 'motion';
+
+type ScrollToElementOptions = {
+  offset?: number;
+  duration?: number;
+};
+
+/**
+ * Smooth scroll to an element by ID
+ */
+export const scrollToElement = (
+  id: string,
+  { offset = 0, duration = 0.5 }: ScrollToElementOptions = {},
+): void => {
+  const el = document.getElementById(id);
+  if (el == null) return;
+  const target = el.getBoundingClientRect().top + window.scrollY + offset;
+  animate(window.scrollY, target, {
+    duration,
+    onUpdate: (v) => window.scrollTo(0, v),
+  });
+};
+
+/**
+ * Smooth scroll within a container by a relative distance
+ */
+export const scrollWithinContainer = (
+  container: HTMLElement,
+  distance: number,
+  duration = 0.2,
+): void => {
+  animate(container.scrollTop, container.scrollTop + distance, {
+    duration,
+    onUpdate: (v) => {
+      container.scrollTop = v;
+    },
+  });
+};

+ 2 - 5
apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx

@@ -4,7 +4,6 @@ import dynamic from 'next/dynamic';
 import type { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
@@ -14,6 +13,7 @@ import type {
 } from '~/client/components/Common/Dropdown/PageItemControl';
 import type { RevisionLoaderProps } from '~/client/components/Page/RevisionLoader';
 import { exportAsMarkdown } from '~/client/services/page-operation';
+import { scrollWithinContainer } from '~/client/util/smooth-scroll';
 import { toastSuccess } from '~/client/util/toastr';
 import { PagePathNav } from '~/components/Common/PagePathNav';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
@@ -112,10 +112,7 @@ const scrollToFirstHighlightedKeyword = (scrollElement: HTMLElement): void => {
     toElem.getBoundingClientRect().top -
     scrollElement.getBoundingClientRect().top -
     SCROLL_OFFSET_TOP;
-  animateScroll.scrollMore(distance, {
-    containerId: scrollElement.id,
-    duration: 200,
-  });
+  scrollWithinContainer(scrollElement, distance);
 };
 const scrollToFirstHighlightedKeywordDebounced = debounce(
   500,

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

+ 4 - 4
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",
@@ -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',
       },
     },
   },

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

+ 2 - 0
packages/presentation/.gitignore

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

+ 1 - 1
packages/presentation/package.json

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

Fichier diff supprimé car celui-ci est trop grand
+ 328 - 74
pnpm-lock.yaml


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff