Deep Dive into the p5.js Website: A Technical Breakdown

Understanding how the official p5.js reference website is architected, built, and deployed — covering everything from the framework choices to the iframe-based code runner, i18n system, and accessibility infrastructure.

Table of Contents

  1. Overview & Tech Stack
  2. Project Structure
  3. Astro Configuration & Build Pipeline
  4. Component Architecture
  5. The Interactive Code Embed System
  6. Internationalization (i18n)
  7. Content Collections & Locale Fallback Logic
  8. Navigation & Jump-To System
  9. Accessibility Infrastructure
  10. OpenProcessing Integration
  11. Service Worker & Caching Strategy
  12. Build-Time Scripting & Reference Generation
  13. Global State Management
  14. Styling System
  15. Testing & CI/CD

1. Overview & Tech Stack

The p5.js website (p5js.org) is a statically generated documentation and community platform built with the following core technologies:

Category Technology
Framework Astro v5
UI Components Preact v10 (with React compat layer)
Content MDX + YAML via Astro content collections
Styling Tailwind CSS v3 + SCSS modules
Code Editor CodeMirror 6 (@uiw/react-codemirror)
Search Fuse.js (client-side fuzzy search)
Testing Vitest + Playwright (for a11y tests)
Deployment Static build, service-worker-powered offline support

The choice of Astro is strategically important. Astro is designed for content-heavy sites and ships zero JavaScript by default — components only hydrate on the client when explicitly annotated with directives like client:load or client:only. This means the reference documentation is fast and fully static, while interactive UI elements (like the code editor or locale switcher) opt into client-side rendering on-demand.

Preact (not React) is used as the component runtime. Since Preact is a 3KB alternative to React with identical APIs, this significantly reduces the bundle size. The @astrojs/preact integration is configured with compat: true to transparently support third-party React libraries (like CodeMirror's React bindings) without shipping React itself.


2. Project Structure

At a high level, the repository is organized as:

Plaintext snippet
├── .github/               # CI/CD workflows and issue templates
├── docs/                  # Contributor documentation
├── public/                # Static assets (fonts, images, example media files)
├── src/
│   ├── api/               # External API integration (OpenProcessing)
│   ├── cached-data/       # Pre-fetched JSON from OpenProcessing
│   ├── components/        # Reusable UI components (Astro + Preact)
│   ├── content/           # Astro content collections (MDX + YAML)
│   │   ├── examples/      # Code examples (localized)
│   │   ├── reference/     # API reference entries
│   │   ├── tutorials/     # Tutorials (localized)
│   │   ├── events/        # Community events
│   │   ├── libraries/     # Third-party p5 libraries
│   │   ├── people/        # Contributors and team
│   │   ├── ui/            # UI translation strings (YAML per locale)
│   │   └── banner/        # Site-wide banner content
│   ├── globals/           # Shared runtime constants and state
│   ├── i18n/              # Locale utilities and constants
│   ├── layouts/           # Page layout wrappers
│   ├── pages/             # Astro file-based routing
│   └── scripts/           # Node.js build-time scripts
├── styles/                # Global SCSS and variables
├── test/                  # Unit and accessibility tests
├── astro.config.mjs       # Astro + integration config
└── package.json

The separation between src/scripts/ (build-time Node.js code) and src/components/ (browser code) is intentional and important — Astro enforces this boundary at the framework level.


3. Astro Configuration & Build Pipeline

The astro.config.mjs file is the heart of the build configuration:

Js snippet
export default defineConfig({
  site: 'https://p5js.org',
  compressHTML: false,
  integrations: [
    mermaid({ autoTheme: true }),
    preact({ compat: true }),
    mdx(),
    tailwind(),
    fast(),       // custom compression plugin
    serviceWorker({ workbox: { ... } }),
  ],
  prefetch: {
    defaultStrategy: "viewport",
    prefetchAll: true,
  },
  trailingSlash: "always",
  build: { format: "directory", concurrency: 2 },
  image: {
    domains: ["openprocessing.org"],
    service: passthroughImageService()
  },
  markdown: {
    shikiConfig: { theme: 'github-light-high-contrast' },
  },
});

Key design decisions:

The build also defines several custom npm scripts for generating specific sections of the site:

Json snippet
"build:reference": "tsx ./src/scripts/builders/reference.ts",
"build:contributor-docs": "tsx ./src/scripts/builders/contribute.ts",
"build:contributors": "tsx ./src/scripts/builders/people.ts",
"build:search": "tsx ./src/scripts/builders/search.ts",
"build:p5-version": "tsx ./src/scripts/p5-version.ts",

These builders run before or independently from the main Astro build. They clone the upstream p5.js library repository (using simple-git), parse its JSDoc/YUIDoc annotations, and generate .mdx and .yaml files that Astro then processes as normal content. This means the reference documentation is always generated from the actual source code of the library.


4. Component Architecture

The site uses a dual-component model: .astro files for server-rendered, mostly-static components, and .tsx/.jsx (Preact) files for interactive, client-hydrated components.

Astro Components (Server-Rendered)

These run entirely at build time and ship pure HTML:

Preact Components (Client-Hydrated)

These use client:load or client:only directives:

Shared Utility Components


5. The Interactive Code Embed System

This is arguably the most technically interesting part of the codebase. The site needs to run live p5.js sketches in the browser, allow editing, and do so efficiently when multiple sketches appear on the same page.

The system has two distinct implementations for two contexts:

`CodeEmbed/index.jsx` — The Full Editor (Client-Only)

Used on example pages where code is editable. On mount, it:

  1. Injects a <script id="p5ScriptTag" src={cdnLibraryUrl}> tag into the document <head> once (idempotent — it checks if the tag already exists).
  2. Renders a CodeMirror editor via @uiw/react-codemirror with JavaScript syntax highlighting and line wrapping.
  3. Auto-detects canvas dimensions by parsing createCanvas(w, h) from the code string using a regex, and sets the preview iframe size accordingly.
  4. Detects DOM-creating p5 API calls (createButton, createDiv, etc.) and adds extra height to accommodate them.
Js snippet
const canvasMatch =
  /createCanvas\(\s*(\d+),\s*(\d+)\s*(?:,\s*(?:P2D|WEBGL)\s*)?\)/m.exec(
    initialCode,
  );

The editor buttons (Play, Stop, Reset, Copy) are rendered as CircleButton components with accessible aria-label attributes. Pressing "Run" calls updateOrReRun(), which resets previewCodeString to force a re-render of the CodeFrame iframe.

`CodeEmbed/frame.tsx` — The Iframe Runner

The actual p5.js sketch runs inside a sandboxed <iframe> with srcDoc set to a self-contained HTML document. The document wrapping function is notable:

Ts snippet
const wrapInMarkup = (code: CodeBundle) => `<!DOCTYPE html>
<meta charset="utf8" />
<base href="${code.base || "/assets/"}" />
<style>html, body { margin: 0; padding: 0; } canvas { display: block; }</style>
<body>${code.htmlBody || ""}</body>
<script id="code" type="text/javascript">${wrapSketch(code.js) || ""}</script>
...`;

The wrapSketch() function automatically wraps bare p5.js code in a setup() function if neither setup nor a p5 constructor call is detected — this is a quality-of-life feature for examples that show just a few drawing commands.

The p5.js Library Loading Problem

Loading p5.min.js inside each iframe naively would cause multiple full downloads — one per sketch on a page, every time the sketch runs. To solve this, the system uses postMessage:

  1. The parent page loads p5.min.js once (via the <script id="p5ScriptTag"> in <head>).
  2. When a CodeFrame mounts and becomes visible, it fetch()es the script's text content.
  3. It sends that text via postMessage to the iframe: iframeRef.current.contentWindow.postMessage({ sender: cdnLibraryUrl, message: p5ScriptText }, "*").
  4. Inside the iframe, a message event listener receives the text, creates a <script> element, and injects it into <head>.

This lets the browser cache the library in one place while every iframe reuses that cached content.

Intersection Observer for Performance

Both CodeFrame and CodeFrameForServer use IntersectionObserver to stop rendering sketches that scroll out of view:

Ts snippet
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        iframeRef.current.style.removeProperty("display");
      } else {
        iframeRef.current.style.display = "none";
      }
    });
  },
  { rootMargin: "20px" },
);

Setting display: none on an iframe stops the browser from running the sketch's draw() loop every frame, which is critical on pages (like the reference) that embed many simultaneous sketches.

iframe Sandbox

The iframe uses sandbox="allow-scripts allow-popups allow-modals allow-forms allow-same-origin". The allow-same-origin flag is required for the postMessage communication to work across the parent → iframe boundary in some browser configurations.

`CodeEmbed/frameForServer.tsx` — Static Preview

A simpler version used on SSR/static pages where the code is not editable. Instead of the postMessage trick, it includes p5.js directly via a CDN <script src> tag baked into the srcDoc. The Intersection Observer is still used, but instead of toggling display: none, it fully removes and restores the iframe's srcDoc content.


6. Internationalization (i18n)

The site supports six locales:

Internationalization is built from scratch, without using any external i18n library. It is split across two files:

`src/i18n/const.ts`

Ts snippet
export const defaultLocale = "en";
export const nonDefaultSupportedLocales = ["es", "hi", "ko", "zh-Hans"];
export const supportedLocales = [defaultLocale, ...nonDefaultSupportedLocales];

`src/i18n/utils.ts`

The key function is getUiTranslator(), which:

  1. Reads a src/content/ui/{locale}.yaml file for the requested locale.
  2. Reads the English fallback file.
  3. Returns a t(...keys) function that does recursive key lookup with English fallback.
Ts snippet
const t = (...args: string[]) => {
  let val = findTranslation(args, currentLocaleDict);
  if (val === undefined) val = findTranslation(args, defaultLocaleDict);
  if (val === undefined) return args[args.length - 1]; // key as last-resort fallback
  return val;
};

The variadic ...args signature supports nested keys, e.g., t("referenceCategories", "modules", "color"), which maps to a nested YAML structure. This is used extensively for categorized translations (tutorial categories, reference categories, callout titles, attribution text, etc.).

Locale detection from the URL is done via getCurrentLocale(pathname), which uses a regex to match the path against known locale prefixes. The LocaleSelect Preact component constructs the new locale's URL by replacing the locale prefix in the current path.

The `OutdatedTranslationBanner` Component

When a page exists in a non-English locale but has fallen behind the English source, the site shows a banner. The banner text itself is hardcoded in the component (not in YAML translation files) to avoid a chicken-and-egg problem — if the translation files are outdated, you can't rely on them to display an accurate "this is outdated" message. The checkTranslationBanner utility compares content file modification timestamps to determine staleness.


7. Content Collections & Locale Fallback Logic

Astro content collections are used for all structured content. The most important utility is the locale fallback system in src/pages/_utils.ts.

`getCollectionInLocaleWithFallbacks()`

Ts snippet
export const getCollectionInLocaleWithFallbacks = memoize(
  async (collectionName, locale) => {
    const localizedEntries = await getCollectionInLocale(
      collectionName,
      locale,
    );
    const defaultLocaleCollection =
      await getCollectionInDefaultLocale(collectionName);
    const filteredDefaultEntries = defaultLocaleCollection.filter(
      (defaultEntry) => {
        return !localizedEntries.some(
          (localeEntry) =>
            removeLocalePrefix(localeEntry.id) ===
            removeLocalePrefix(defaultEntry.id),
        );
      },
    );
    return [...localizedEntries, ...filteredDefaultEntries];
  },
  (...args) => args.join("_"),
);

This function merges localized entries with English fallbacks. For any content piece that doesn't exist in the requested locale, the English version is shown. The result is memoized with Lodash's memoize using a string key of all arguments — this is a significant performance optimization since this function is called many times during a build (once per page, for multiple collections).

Slug Transformation

Examples historically used a different URL scheme than what Astro generates from directory structure. The exampleContentSlugToLegacyWebsiteSlug() function handles backward compatibility:

Ts snippet
export const exampleContentSlugToLegacyWebsiteSlug = (slug: string): string =>
  slug
    .replace(/^[\w-]+?\//, "") // strip locale prefix
    .replace(/\d+_(.*?)\/\d+_(.*?)\/description$/, "$1-$2") // "123_topic/456_sub/description" → "topic-sub"
    .replace(/_/g, "-"); // underscores → hyphens

This preserves all existing inbound links from external sites that linked to the old URL format.

`generateJumpToState()`

This function powers the in-page "Jump To" navigation panel. It:

  1. Fetches all entries in a collection for the current locale (with fallbacks).
  2. Extracts categories (from data.category for tutorials, from slug structure for examples, from a static config for reference).
  3. Builds a list of JumpToLink objects, marking the currently-viewed entry as current: true.
  4. Moves the currently-active category to the top of the list for discoverability.

The result is stored in a module-level jumpToState singleton during the build, then consumed by the NavPanels component.


8. Navigation & Jump-To System

The navigation is a two-panel system:

  1. Main nav panel — Site-wide links (Reference, Tutorials, Examples, etc.) plus Editor and Donate CTAs.
  2. Jump-to panel — Page-specific in-content navigation (table of contents for the current page's section).

Both panels are managed by the NavPanels Preact component, which tracks open/closed state for each:

Ts snippet
const [isOpen, setIsOpen] = useState({ main: false, jump: false });

Mobile behavior differs from desktop:

This responsive behavior is handled with a ResizeObserver on document.body rather than CSS media queries, because the state changes need to trigger JavaScript re-renders (not just style changes):

Ts snippet
const documentObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    if (!isMobile && entry.contentRect.width < 768) {
      setIsMobile(true);
      setIsOpen({ main: false, jump: false });
    } else if (isMobile && entry.contentRect.width >= 768) {
      setIsMobile(false);
      setIsOpen({ main: true, jump: true });
    }
  }
});
documentObserver.observe(document.body);

The jump-to state is set at build time in each layout (e.g., ExampleLayout.astro calls setJumpToState(jumpToState)) and then read by the NavPanels component via the global jumpToState singleton from src/globals/state.ts.


9. Accessibility Infrastructure

Accessibility is treated as a first-class concern with dedicated infrastructure:

`AccessibilitySettings` Component

Exposes four toggleable settings, stored in localStorage:

Setting Effect
dark-theme Toggles dark color scheme via CSS class on <html>
monochrome-theme Replaces all colors with grayscale
show-alt-text Makes the visually-hidden alt text badge on images visible
reduced-motion Disables CSS animations and transitions

The settings are loaded from localStorage and applied to document.documentElement on component mount. To avoid a flash of un-themed content, the BaseLayout.astro injects an inline <script> in <head> that reads and applies these settings before the page renders:

Js snippet
<script is:inline>
  const settings = ["dark-theme", "monochrome-theme", "show-alt-text", "reduced-motion"];
  const storedSettings = settings.filter(s => localStorage.getItem(s) === "true");
  if (storedSettings.length === 0) storedSettings.push("light-theme");
  document.documentElement.className = `${storedSettings.join(" ")} ${document.documentElement.className}`;
</script>

`useLiveRegion` Hook

A custom Preact hook that provides an announce(message) function for ARIA live region notifications:

Ts snippet
const announce = (message: string, clearMessage = 1000) => {
  const node = ref.current;
  clearTimer();
  node.textContent = message;
  timerRef.current = setTimeout(() => {
    if (node) node.textContent = "";
  }, clearMessage);
};

This is used in CopyCodeButton (announces "Code copied to clipboard") and CodeEmbed (announces "Sketch is running" / "Sketch stopped" / "Code reset"). Screen reader users receive audio feedback for all interactive code embed actions.

Skip Navigation

Every page has a "Skip to main content" link at the very top of the <nav> element:

Html snippet
<a href="#main-content" class="skip-to-main">Skip to main content</a>

This links to <main id="main-content"> in BaseLayout.astro.

`Callout` Component ARIA

The Callout component renders with proper role="region" and aria-label, using the localized callout title as the accessible name:

Astro snippet
<section role="region" aria-label={String(t('calloutTitles', props.title || "Try this!"))}>

Image Alt Text System

The custom Image component renders alt text both as an HTML alt attribute (for screen readers) and as a visible overlay badge when show-alt-text is enabled. The badge is aria-hidden since it's redundant with the alt attribute — it's purely for sighted users who want to review alt text quality.

Playwright Accessibility Tests

The test suite includes test/a11y/ — Playwright tests using @axe-core/playwright — which run automated accessibility audits against the rendered pages.


10. OpenProcessing Integration

The "Sketches" section of the community area showcases creative p5.js work from OpenProcessing. Rather than making live API calls at runtime (which would fail during static builds), all sketch data is pre-fetched and committed to the repository as JSON files in src/cached-data/.

Data Structure

Two curation JSON files exist:

Individual sketch detail files live in src/cached-data/openprocessing-sketches/{id}.json, each containing metadata like title, description, instructions, width, height, mode, and createdOn.

`src/api/OpenProcessing.ts`

The API module provides typed access to this cached data:

Ts snippet
export async function getCurationSketches(): Promise<OpenProcessingCurationResponse> {
  const payload2024 = await readJson(CURATION_2024_FILE);
  const payload2025 = await readJson(CURATION_2025_FILE);
  // Priority sketches are featured first
  const prioritySketches = payload2025
    .filter((s) => priorityIds.includes(String(s.visualID)))
    .sort(
      (a, b) =>
        priorityIds.indexOf(String(a.visualID)) -
        priorityIds.indexOf(String(b.visualID)),
    );
  // Merge, deduplication handled by checking if individual sketch file exists
  const allSketches = [...prioritySketches, ...payload2024];
  const availableSketches = [];
  for (const sketch of allSketches) {
    if (await exists(SKETCH_FILE(sketch.visualID)))
      availableSketches.push(sketch);
  }
  return availableSketches;
}

A handpicked priorityIds array ensures specific high-quality sketches from the 2025 curation always appear at the top of the gallery.

Sketches are embedded in their own pages via the OpenProcessing embed URL:

Ts snippet
export const makeSketchEmbedUrl = (id: number) =>
  `https://openprocessing.org/sketch/${id}/embed/?plusEmbedFullscreen=true&plusEmbedInstructions=false`;

The SketchLayout.astro calculates an aspect ratio for the iframe based on the sketch's width and height metadata, adding 50px to account for OpenProcessing's own toolbar. A ScalingIframe component handles responsive scaling.


11. Service Worker & Caching Strategy

The site uses astrojs-service-worker (backed by Workbox) to enable offline support and fast repeat visits.

Js snippet
serviceWorker({
  workbox: {
    globPatterns: ["**/*.{css,js,jpg,json,png,svg,ico,woff,woff2}"],
    clientsClaim: true,
    runtimeCaching: [
      {
        urlPattern: ({ url }) => url.pathname.endsWith(".html"),
        handler: "CacheFirst",
        options: {
          cacheName: "html-pages-cache",
          expiration: { maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 * 7 }
        },
      },
    ],
  },
}),

This is particularly important for p5.js's audience — creative coders who may work offline or in low-connectivity environments.


12. Build-Time Scripting & Reference Generation

The src/scripts/utils.ts module provides the foundational utilities for all build scripts:

Library Repository Cloning

Ts snippet
export const cloneLibraryRepo = async (localSavePath, repoUrl, branch) => {
  const hasRecentRepo =
    branch !== "main" && repoExists && (await fileModifiedSince(localSavePath));
  if (!hasRecentRepo) {
    await git.clone(repoUrl, localSavePath, [
      "--depth",
      "1",
      "--filter=blob:none",
      "--branch",
      branch,
    ]);
  }
};

The --depth 1 --filter=blob:none flags create a shallow, blobless clone — this is a Git optimization that downloads only the tree structure without any file content initially, then fetches only the files actually needed. This dramatically reduces clone time for the large p5.js library repository.

The function includes a staleness check: if the local clone is less than 72 hours old and we're not on main, it skips re-cloning. This makes incremental builds much faster.

Preprocessor Path Fix

The p5.js library's documentation preprocessor (docs/preprocessor.js) uses an absolute process.cwd() path to reference parameterData.json. When cloned into an arbitrary directory, this breaks. The build script automatically patches it to use __dirname instead:

Ts snippet
preprocessorContent = preprocessorContent.replace(
  "path.join(process.cwd(), 'docs', 'parameterData.json')",
  `path.join(__dirname, 'parameterData.json')`,
);

Content File Discovery

getContentFilePaths() recursively finds all .mdx and .yaml files in a directory, used by the reference and contributor-docs builders to enumerate what needs to be generated.

rewriteRelativeMdLinks() converts relative .md links (from the source library's GitHub docs) to the Astro URL convention (no extension, trailing slash):

Plaintext snippet
./access.md  →  ./access/

This ensures that imported markdown from external repos (like contributor-docs sourced from the p5.js library repo) has correct navigation links on the website.


13. Global State Management

The site has minimal client-side state. For the build-time inter-layout communication needed by the nav system, a module-level singleton is used:

Ts snippet
// src/globals/state.ts
export let jumpToState: JumpToState | null = null;
export const setJumpToState = (newJumpToState: JumpToState | null) => {
  jumpToState = newJumpToState;
};

This pattern works because during Astro's static build, all layouts run in the same Node.js process, so module state is shared. Each layout calls setJumpToState() with its page-specific links, overwriting any previous value. Since pages are built sequentially (or with controlled concurrency), each page gets its own jump-to state at render time.

The NavPanels component receives jumpToState as a prop (not imported directly from the module) to maintain clean component boundaries.


14. Styling System

Styling uses a combination of Tailwind CSS utility classes and SCSS modules:

Tailwind

Used for the majority of component-level styling. The @astrojs/tailwind integration generates a stylesheet from utility classes found across all component files. Custom design tokens (colors, spacing) are defined in tailwind.config.mjs and referenced via CSS custom properties.

SCSS Modules

Used in a few places where Tailwind's constraint-based approach is limiting — particularly for complex state-driven styles. The Nav/styles.module.scss is the primary example, handling the intricate open/closed/mobile/desktop state matrix with nested selectors.

Global SCSS

styles/global.scss defines:

Variables

styles/variables.scss defines breakpoint constants ($breakpoint-tablet, $breakpoint-desktop) used across SCSS modules to keep responsive breakpoints consistent.


15. Testing & CI/CD

Unit Tests (Vitest)

Vitest is configured with two test projects:

Ts snippet
test: {
  projects: [
    { test: { name: "DOM", environment: "jsdom", include: ["test/**/*"] } },
    { test: { name: "node", include: ["test/pages/*"] } },
  ];
}

The test/api/OpenProcessing.test.ts file tests the curation API utilities in isolation with mock data.

Accessibility Tests (Playwright + axe-core)

The test/a11y/ suite uses @axe-core/playwright to run automated WCAG compliance checks against rendered pages. These tests are run with npm run test:a11y and are separate from the unit test suite.

GitHub Actions

The .github/workflows/ directory contains:

Translation Tracker

The .github/actions/translation-tracker/ is a custom GitHub Action written in Node.js. It compares the modification dates of localized content files against their English counterparts and generates a report of outdated translations, which is then posted as a PR comment or issue update. This is a sophisticated piece of infrastructure for maintaining multilingual documentation quality at scale.


Summary

The p5.js website is a well-engineered documentation platform with several standout technical decisions:

  1. Astro's island architecture keeps the page fast by default while enabling rich interactivity only where needed.
  2. The postMessage-based p5.js library sharing between parent page and iframes is an elegant solution to the "same library, many iframes" performance problem.
  3. Build-time reference generation from actual library source code ensures the documentation is never stale relative to the library.
  4. Locale fallback logic with memoization makes internationalization transparent to content authors while keeping build performance acceptable.
  5. Accessibility as a first-class feature — not bolted on, but architecturally integrated from the component level (useLiveRegion, ARIA attributes) through to automated Playwright test coverage.
  6. IntersectionObserver-based sketch lifecycle management prevents unnecessary CPU usage from p5.js draw() loops on pages with multiple sketches.

The codebase reflects a thoughtful balance between developer experience, end-user performance, accessibility, and the unique requirements of a creative-coding community platform.