design.md 10 KB

Design Document: 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:

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:

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

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

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]

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
// 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
// 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
// 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