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

Merge pull request #10840 from growilabs/support/optimize-presentation

imprv(presentation): Decouple Marp from GrowiSlides
mergify[bot] 2 недель назад
Родитель
Сommit
46d95cbfb9

+ 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)
 ### Components Track (component-specific CSS)
 
 
 - **For**: CSS needed only by specific components
 - **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
 - **Output**: Pure JS modules (no CSS imports) — Turbopack sees them as regular JS
 
 
 ## How It Works
 ## How It Works
 
 
 1. **Entry point** (`ComponentName.vendor-styles.ts`): imports CSS via Vite `?inline` suffix, which inlines CSS as a string
 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`
 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
 ### 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/`
 - 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/`
 - URL references in prebuilt JS are rewritten from `/assets/` to `/static/fonts/`
 - Fonts are served by the existing `express.static(crowi.publicDir)` middleware
 - 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
 ## Build Pipeline Integration
 
 
@@ -106,7 +106,7 @@ turbo.json tasks:
   dev:pre:styles-components  →  dev (dependency)
   dev:pre:styles-components  →  dev (dependency)
 
 
 Inputs:  vite.vendor-styles-components.ts, src/**/*.vendor-styles.ts, package.json
 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
 ## Important Caveats

+ 1 - 1
apps/app/.gitignore

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

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

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

+ 1 - 4
apps/app/tsconfig.json

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

+ 4 - 4
apps/app/turbo.json

@@ -15,7 +15,7 @@
     },
     },
     "pre:styles-components": {
     "pre:styles-components": {
       "dependsOn": ["^build"],
       "dependsOn": ["^build"],
-      "outputs": ["src/**/*.vendor-styles.prebuilt.js"],
+      "outputs": ["src/**/*.vendor-styles.prebuilt.*"],
       "inputs": [
       "inputs": [
         "vite.vendor-styles-components.ts",
         "vite.vendor-styles-components.ts",
         "src/**/*.vendor-styles.ts",
         "src/**/*.vendor-styles.ts",
@@ -56,11 +56,11 @@
     },
     },
     "dev:pre:styles-components": {
     "dev:pre:styles-components": {
       "dependsOn": ["^dev"],
       "dependsOn": ["^dev"],
-      "outputs": ["src/**/*.vendor-styles.prebuilt.js"],
+      "outputs": ["src/**/*.vendor-styles.prebuilt.*"],
       "inputs": [
       "inputs": [
         "vite.vendor-styles-components.ts",
         "vite.vendor-styles-components.ts",
-        "src/**/*.vendor-styles.ts",
-        "!src/**/*.vendor-styles.prebuilt.js",
+        "src/**/*.vendor-styles.*",
+        "!src/**/*.vendor-styles.prebuilt.*",
         "package.json"
         "package.json"
       ],
       ],
       "outputLogs": "new-only"
       "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/
 // 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 {
 function moveAssetsToPublic(): Plugin {
   return {
   return {
     name: 'move-assets-to-public',
     name: 'move-assets-to-public',
@@ -34,8 +34,8 @@ function moveAssetsToPublic(): Plugin {
       }
       }
       fs.rmdirSync(srcDir);
       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,
         cwd: __dirname,
       });
       });
       for (const file of prebuiltFiles) {
       for (const file of prebuiltFiles) {
@@ -63,7 +63,7 @@ export default defineConfig({
       input: entries,
       input: entries,
       output: {
       output: {
         format: 'es',
         format: 'es',
-        entryFileNames: '[name].js',
+        entryFileNames: '[name].ts',
       },
       },
     },
     },
   },
   },

+ 1 - 1
biome.json

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

+ 2 - 0
packages/presentation/.gitignore

@@ -1 +1,3 @@
 /dist
 /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": {
   "scripts": {
+    "generate:marpit-base-css": "node scripts/extract-marpit-css.ts",
     "build": "vite build",
     "build": "vite build",
-    "build:vendor-styles": "vite build --config vite.vendor-styles.ts",
     "clean": "shx rm -rf dist",
     "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "dev": "vite build --mode dev",
     "watch": "pnpm run dev -w --emptyOutDir=false",
     "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 Head from 'next/head';
 import ReactMarkdown from 'react-markdown';
 import ReactMarkdown from 'react-markdown';
 
 
-import type { PresentationOptions } from '../consts';
+import { MARP_CONTAINER_CLASS_NAME, type PresentationOptions } from '../consts';
 import {
 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 * as extractSections from '../services/renderer/extract-sections';
 import {
 import {
   PresentationRichSlideSection,
   PresentationRichSlideSection,
@@ -43,8 +42,7 @@ export const GrowiSlides = (props: Props): JSX.Element => {
     ? PresentationRichSlideSection
     ? PresentationRichSlideSection
     : RichSlideSection;
     : RichSlideSection;
 
 
-  const marpit = presentation ? presentationMarpit : slideMarpit;
-  const { css } = marpit.render('');
+  const css = presentation ? PRESENTATION_MARPIT_CSS : SLIDE_MARPIT_CSS;
   return (
   return (
     <>
     <>
       <Head>
       <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 type { PresentationOptions } from '../consts';
 import { GrowiSlides } from './GrowiSlides';
 import { GrowiSlides } from './GrowiSlides';
-import { MarpSlides } from './MarpSlides';
 
 
 import styles from './Slides.module.scss';
 import styles from './Slides.module.scss';
 
 
+const MarpSlides = lazy(() =>
+  import('./MarpSlides').then((mod) => ({ default: mod.MarpSlides })),
+);
+
 export type SlidesProps = {
 export type SlidesProps = {
   options: PresentationOptions;
   options: PresentationOptions;
   children?: string;
   children?: string;
@@ -19,7 +22,18 @@ export const Slides = (props: SlidesProps): JSX.Element => {
   return (
   return (
     <div className={`${styles['slides-styles']}`}>
     <div className={`${styles['slides-styles']}`}>
       {hasMarpFlag ? (
       {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}>
         <GrowiSlides options={options} presentation={presentation}>
           {children}
           {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 ReactMarkdownOptions } from 'react-markdown';
 import type { Options as RevealOptions } from 'reveal.js';
 import type { Options as RevealOptions } from 'reveal.js';
 
 
+export const MARP_CONTAINER_CLASS_NAME = 'marpit';
+
 export type PresentationOptions = {
 export type PresentationOptions = {
   rendererOptions: ReactMarkdownOptions;
   rendererOptions: ReactMarkdownOptions;
   revealOptions?: RevealOptions;
   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 { Marp } from '@marp-team/marp-core';
 import { Element } from '@marp-team/marpit';
 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.
 // Add data-line to Marp slide.
 // https://github.com/marp-team/marp-vscode/blob/d9af184ed12b65bb28c0f328e250955d548ac1d1/src/plugins/line-number.ts
 // https://github.com/marp-team/marp-vscode/blob/d9af184ed12b65bb28c0f328e250955d548ac1d1/src/plugins/line-number.ts
@@ -12,6 +14,7 @@ const lineNumber = (md) => {
     md.renderer.rules;
     md.renderer.rules;
 
 
   // Enable line sync by per slides
   // 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) => {
   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');
     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"]
+    }
+  }
+}