A design-forward, open-source link-in-bio page with a built-in CMS. Built with Vue 3, PrimeVue, and Tailwind CSS.
Build Hooks — TTS, image optimization, OG meta pre-rendering; extendable via theme/user config Custom Collections — add new content types via config; see guide below Widget System — animated text, backgrounds, interactive elements; see guide below Docs — Markdown docs, nav tree, search, section filters; extensible via _meta.json Error Handling & Security — file upload validation, path sanitization, password encryption, CMS auth (UI only); see guide below Extensibility — theme/override staging, config merging, build hooks, custom collections; see guides below
Build hooks allow you to extend the build process for tasks like TTS audio generation, image optimization, OG meta pre-rendering, and more. Hooks are registered in theme/user config files and run during vite build.
To add a custom build hook:
- Create a function matching the BuildHook interface (see
src/lib/config.ts). - Register it in your theme or user config under
buildHooks. - Hooks can run after collection build or after the full bundle.
Example:
import { createTtsHook } from "./build-hooks/tts-hook";
const config = {
buildHooks: [createTtsHook({ collection: "blog", voice: "af_heart" })],
};See src/themes/bento/build-hooks/tts-hook.ts for a real example.
You can add new content collections (e.g. Projects, Docs, FAQ) by editing your config file:
contentCollections: {
projects: {
directory: "content/projects",
format: "markdown",
label: "Projects",
icon: "Folder",
itemSchema: [
{ $formkit: "text", name: "title", label: "Title" },
{ $formkit: "date", name: "date", label: "Date" },
],
newItem: () => ({ title: "", date: "" }),
},
}Collections support schema migrations, validation, custom editors, and build hooks. See src/lib/config.ts for all options.
Widgets are animated text, backgrounds, and interactive elements. To add widgets, use the CMS or extend the theme config. See src/themes/bento/platformkit.config.ts for schema options.
Docs are markdown files in content/docs/. Add new pages by creating .md files. Organize sections with _meta.json. The sidebar/nav tree is auto-generated. See src/themes/bento/DocsSection.vue for nav logic.
CMS endpoints are UI-protected only; server-side auth is not enforced. File uploads are validated for type, size, and path. Passwords are encrypted in session storage. See TODO.md for known issues and recommended mitigations.
PlatformKit is designed for easy extension:
- Themes: Add new themes in
src/themes/or your content repo. - Overrides: Replace any component via
src/overrides/. - Build Hooks: Extend the build process (TTS, image optimization, OG meta, etc.) via config.
- Custom Collections: Add new content types via config.
- Widgets: Add animated text/backgrounds via CMS or theme config.
- Docs: Add new docs pages in
content/docs/, organize with_meta.json. - Error Handling: See TODO.md for known issues and mitigations.
git clone https://github.com/platform-kit/platformkit.git
cd platformkit
npm install
npm run devOpen http://localhost:5173 and click the CMS button (bottom-right) to edit your content.
If you already have a content directory with data.json and an optional uploads/ folder:
npx github:platform-kit/platformkit serve ./my-contentBuild a deployable dist/ folder using your own content — no need to clone the PlatformKit repo:
npx github:platform-kit/platformkit build ./my-contentOutput to a custom directory:
npx github:platform-kit/platformkit build ./my-content --out ./publicThis is ideal for deploying on Vercel, Netlify, or any static host. Create a content repo with your data.json and uploads/, then set the build command to:
npx github:platform-kit/platformkit build .You don't need to fork or clone the PlatformKit codebase. Instead, connect your content repo to Vercel and use the npx build command to pull the app at build time.
1. Create a content repo
Push your content with npm run push (this auto-creates the repo if needed). The repo will contain:
my-platformkit-site/
├── data.json ← your CMS content
└── uploads/ ← your uploaded images
├── avatar.png
└── banner.jpg
2. Connect it to Vercel
- Go to vercel.com/new and import your content repo
- Configure the project settings:
| Setting | Value |
|---|---|
| Build Command | npx github:platform-kit/platformkit build . |
| Output Directory | dist |
| Install Command | (leave blank) |
3. Deploy
Vercel will run the build command, which:
- Installs the PlatformKit package from GitHub (pre-built app included)
- Copies it to
dist/ - Injects your
data.jsonanduploads/into the output - Vercel deploys
dist/to the edge
4. Update your site
Whenever you push changes to your content repo (new links, updated bio, etc.), Vercel automatically rebuilds and redeploys. You can update content locally with npm run dev + the CMS, then npm run push to sync to your content repo.
platformkit serve <content-dir> Serve your site locally
platformkit build <content-dir> Build a static site into ./dist
platformkit deploy Deploy Supabase migrations & edge functions
platformkit --help Show help
Options:
--port, -p <port> Port for serve (default: 3000)
--out, -o <dir> Output directory for build (default: ./dist)
--project-ref Supabase project ref for deploy
├── bin/cli.mjs CLI entry point for npx
├── default-data.json Template seed content (committed)
├── cms-data.json Your personal CMS data (gitignored)
├── content/
│ └── blog/ Blog post .md files (gitignored)
├── public/
│ ├── data.json Production content (gitignored)
│ ├── blog/ Static blog JSON (generated at build)
│ ├── rss.xml RSS feed (generated)
│ ├── manifest.json PWA manifest (generated)
│ ├── robots.txt Search engine crawl rules
│ └── uploads/ Uploaded images (gitignored)
├── scripts/
│ ├── export-data.mjs Export CMS data to clipboard
│ ├── export-to-github.mjs Push content to GitHub repo
│ ├── import-from-github.mjs Pull content from GitHub repo
│ └── clean-uploads.mjs Remove orphaned upload files
└── src/
├── App.vue Main app layout
├── components/ CMS dialogs, editors, UI components
└── lib/
├── blog.ts Blog types, frontmatter parsing, rendering
├── github.ts GitHub API integration
├── image-convert.ts Image format conversion
├── migrations.ts Schema versioning & migration pipeline
├── model.ts Data types, sanitization, defaults
├── persistence.ts Fetch/save CMS data
├── scheduling.ts Date-based content visibility filtering
└── upload.ts Image upload handling
- Run
npm run dev - Click the CMS button (bottom-right corner)
- Edit your profile, links, and socials
- Changes auto-save to
cms-data.json
| File | Committed? | Purpose |
|---|---|---|
default-data.json |
Yes | Template data for new users |
cms-data.json |
No | Your personal CMS data |
public/content/data.json |
No | Production build content |
public/content/uploads/ |
No | Uploaded images |
public/content/rss.xml |
No | Generated RSS feed |
public/content/blog/ |
No | Static blog JSON (build output) |
content/blog/ |
No | Blog post Markdown source files |
On first run, default-data.json is automatically copied to cms-data.json and public/content/data.json if they don't exist.
Store your personal content in a private GitHub repo, separate from the PlatformKit codebase.
- Create a private GitHub repo for your content
- Copy
.env.exampleto.envand fill in your values:
GITHUB_OWNER=your-username
GITHUB_REPO=my-platformkit-content
GITHUB_TOKEN=ghp_xxxxxxxxxxxx
GITHUB_BRANCH=mainnpm run push # Push local content → GitHub repo
npm run import # Pull GitHub repo → local content
npm run export # Copy CMS data JSON to clipboardFor CI/CD deployments (e.g. Vercel), import content at build time:
npm run import:buildSet the environment variables in your hosting provider's dashboard.
Links, gallery items, embeds, and blog posts all support Publish Date and Expiration Date fields.
- Publish Date — the item won't appear on the public page before this date. For blog posts, this also serves as the display date.
- Expiration Date — the item is hidden after this date.
- Dates are ISO format (e.g.
2025-06-01). An empty date means "no constraint."
By default, scheduling is client-side only — the content is present in the build output but filtered at runtime. To strip scheduled content at build time (so it never reaches the browser), set VITE_SCHEDULE_EXCLUDE_BUILD=1 in your environment.
Blog posts are Markdown files stored in content/blog/ with frontmatter metadata:
---
title: My Post
slug: my-post
date: 2025-06-01
excerpt: A short summary
coverImage: /uploads/cover.jpg
published: true
tags: [tutorial, vue]
publishDate: 2025-06-01
expirationDate:
---
Your markdown content here…- Markdown editor — rich Tiptap editor with formatting, inline images, and code blocks
- Syntax highlighting — 17 built-in languages (JS, TS, Python, Go, Rust, and more)
- Cover images — displayed at the top of each post
- Tags — filter posts with multi-select tag filtering
- RSS feed — auto-generated RSS 2.0 at
/rss.xml - Per-post OG meta — pre-rendered HTML at
dist/content/<slug>/index.htmlwithog:title,og:description,og:imagefor social sharing previews
In development, posts are read/written via Vite middleware. In production, they're compiled to static JSON files at /blog/index.json and /blog/<slug>.json.
A masonry grid supporting images and video:
- Images — uploaded to
public/uploads/ - Video — YouTube URLs, Vimeo URLs, or direct MP4 uploads
- Cover thumbnails — optional poster image for videos
- Tags — per-item tags for filtering
- Lightbox — click to expand
- Up to 50 items
A structured resume section with:
| Section | Fields |
|---|---|
| Bio | Free-text biography (up to 2000 chars) |
| Employment | Company, role, description, start/end year |
| Education | Institution, degree, field, start/end year |
| Skills | Free-form skill tags (up to 50) |
| Achievements | Title, issuer, year, description |
All sections support drag-and-drop reordering.
Add custom HTML tabs to your page — useful for Spotify players, donation widgets, embedded forms, or any third-party embed.
Each embed has:
- Label — tab name
- Icon — Lucide icon for the tab
- HTML — raw HTML/iframe content
- Scheduling — publish date and expiration date
Customize your page appearance with 20+ CSS variables through the CMS:
| Variable | Purpose |
|---|---|
--color-brand |
Primary brand color |
--color-brand-strong |
Hover/strong variant |
--color-accent |
Secondary accent color |
--color-ink |
Main text color |
--color-ink-soft |
Muted text |
--bg |
Page background |
--glass |
Primary frosted-glass surface |
--glass-2 |
Secondary glass |
--glass-strong |
High-opacity glass |
--color-border |
Glass borders |
--color-border-2 |
Subtle dividers |
--card-bg |
Card background |
--card-border |
Card border |
--card-text |
Card text |
--radius-xl |
Large border radius |
--radius-lg |
Standard border radius |
Three presets are available: light, dark, and custom (fully manual).
When deploying with a GitHub token baked in, the CMS is protected by password-based encryption:
- At build time, if
GITHUB_TOKENandCMS_PASSWORDare both set, the token is encrypted using PBKDF2 (600,000 iterations, SHA-256) + AES-256-GCM and embedded in the JS bundle. - At runtime, the CMS prompts for a password to decrypt the token via the Web Crypto API.
- The decrypted token is held in
sessionStoragefor the browser session only.
This means the plaintext token is never stored in the deployed files.
The manifest.json is auto-generated from CMS data:
name/short_namefrom your display nametheme_colorfrom your brand coloriconsfrom your OG image and favicon
- OG meta tags —
og:title,og:description,og:image,og:url, andtwitter:cardinjected at build time - Per-post OG — each blog post gets a pre-rendered HTML page with post-specific social sharing meta
- Favicon — configurable in the CMS
- robots.txt — allows all crawlers
- RSS —
<link rel="alternate">in the HTML head
Inject custom JavaScript or HTML into your page via the CMS:
- Head script — injected into
<head>(analytics, fonts, meta tags) - Body-end script — injected before
</body>(tracking pixels, chat widgets)
| Variable | Required | Purpose |
|---|---|---|
VITE_SITE_URL |
For SEO | Base URL for RSS feeds, OG meta, and absolute URLs |
VITE_SCHEDULE_EXCLUDE_BUILD |
No | Strip expired/future scheduled content at build time |
VITE_PRERENDER |
No | Enable Puppeteer pre-rendering at build time (off by default) |
GITHUB_OWNER |
For sync | GitHub repo owner |
GITHUB_REPO |
For sync | GitHub repo name |
GITHUB_BRANCH |
No | GitHub branch (default: main) |
GITHUB_TOKEN |
For sync | GitHub personal access token |
CMS_PASSWORD |
For CMS lock | Password for encrypting the GitHub token in the build |
SUPABASE_ACCESS_TOKEN |
For deploy | Supabase personal access token (for platformkit deploy in CI) |
SUPABASE_PROJECT_REF |
For deploy | Supabase project ref ID (for platformkit deploy in CI) |
The CMS and GitHub sync features are protected by a password-based encryption system. Your GitHub Personal Access Token (PAT) is never stored in plaintext in the frontend bundle.
- Build-time encryption — During
npm run build, theGITHUB_TOKENfrom your.envfile is encrypted using AES-256-GCM with a key derived from yourCMS_PASSWORDpassword. Only the encrypted blob is embedded in the JavaScript bundle. - Runtime decryption — When you open the CMS, you're prompted to enter the password. The app uses the Web Crypto API to derive the same key via PBKDF2 and decrypt the token in memory.
- Session-only storage — The decrypted token is cached in
sessionStorageso you don't need to re-enter the password within the same browser tab session. It is automatically cleared when the tab is closed. It is never written tolocalStorage, cookies, or any other persistent storage.
| Parameter | Value |
|---|---|
| Algorithm | AES-256-GCM |
| Key derivation | PBKDF2 |
| Hash | SHA-256 |
| Iterations | 600,000 |
| Salt | 16 bytes random |
| IV | 12 bytes random |
| Variable | Purpose | Exposed to browser? |
|---|---|---|
GITHUB_TOKEN |
Your GitHub PAT for content sync | No — encrypted at build time, never in plaintext |
CMS_PASSWORD |
Password used to encrypt/decrypt the token | No — used only at build time for key derivation; not prefixed with VITE_ to prevent accidental client exposure |
GITHUB_OWNER |
GitHub repo owner | Yes (non-secret) |
GITHUB_REPO |
GitHub repo name | Yes (non-secret) |
GITHUB_BRANCH |
GitHub branch | Yes (non-secret) |
VITE_SITE_URL |
Your production domain (for RSS, OG tags) | Yes (non-secret) |
The CMS button in the bottom-right corner requires password authentication before opening. The same CMS_PASSWORD password unlocks both the CMS and the GitHub sync functionality. Once unlocked, subsequent CMS opens in the same browser tab session skip the password prompt.
- The raw PAT is never present in the built JavaScript bundle
- The password is never present in the built JavaScript bundle
- The encrypted token cannot be decrypted without the correct password
- The decrypted token is cached in sessionStorage and cleared when the tab closes
- No secrets are logged, persisted, or transmitted beyond the GitHub API calls
Set CMS_PASSWORD in your .env file to a strong, unique password:
CMS_PASSWORD="your-strong-password-here"This password is required every time you open the CMS in a new browser session.
The content data model is versioned. When the app loads data with an older schemaVersion, it automatically migrates it to the current schema.
- Every
data.jsoncarries aschemaVersionnumber sanitizeModel()runsmigrateToLatest()before processing- Migrations chain: v0 → v1 → v2 → ... runs all intermediate steps
- Old content is never broken — it upgrades transparently
When changing the data model (BioModel, BioProfile, BioLink, SocialLink):
- Bump
CURRENT_SCHEMA_VERSIONinsrc/lib/migrations.ts - Add a migration entry:
{
toVersion: 2,
migrate: (data) => {
data.profile.newField = "default";
data.schemaVersion = 2;
return data;
},
}- Update
sanitizeModel()anddefaultModel()insrc/lib/model.ts - Update
default-data.jsonwith the new field and version
An RSS feed is automatically generated at /rss.xml containing all published blog posts with full HTML content. It is:
- Built at build time — generated into
public/rss.xmlalongside the blog JSON files - Served in dev — available at
http://localhost:8080/rss.xmlfrom the dev server - Auto-discoverable — an
<link rel="alternate">tag in the HTML head lets RSS readers find it
The feed uses your display name and tagline from the CMS as the channel title and description. Set the VITE_SITE_URL environment variable to your production domain so feed URLs point to the correct host:
VITE_SITE_URL=https://yoursite.comIf not set, URLs default to http://localhost:8080.
By default, builds produce a standard SPA shell. To generate fully rendered static HTML for SEO and faster initial paint, enable Puppeteer pre-rendering:
VITE_PRERENDER=1 npm run buildThis launches headless Chrome after the build, navigates each route, waits for the Vue app to finish rendering, and captures the full DOM into static HTML files. The SPA still hydrates on top for interactivity.
Pre-rendered routes:
/(always)- Any layout route with a
prerenderfield in its manifest
Requires Puppeteer as a dev dependency (already installed). On CI, ensure a Chromium binary is available or set PUPPETEER_SKIP_DOWNLOAD to skip and fall back to the SPA shell.
npm run buildThe dist/ folder contains a fully static site. Deploy it anywhere.
For Vercel, the included vercel.json handles SPA routing. Set the GitHub env vars in your Vercel project settings to pull content at build time.
npm run build
npx serve distOr use the built-in CLI:
node bin/cli.mjs ./my-contentThe newsletter feature requires a Supabase project with edge functions and database migrations. Use the built-in deploy command to push everything in one step.
- Install the Supabase CLI:
npm i -D supabase - Log in:
npx supabase login - Create a Supabase project at supabase.com/dashboard
- Set your environment variables in
.env:
VITE_SUPABASE_URL=https://<ref>.supabase.co
VITE_SUPABASE_ANON_KEY=eyJ...Deploy database migrations and all edge functions to your remote Supabase project:
npx github:platform-kit/platformkit deploy --project-ref <your-project-ref>Or set the ref as an environment variable:
export SUPABASE_PROJECT_REF=abcdefghijklmnop
npx github:platform-kit/platformkit deployIf you've already linked the project (npx supabase link), the ref is auto-detected and no flag is needed:
npx github:platform-kit/platformkit deployThere is also a convenience npm script:
npm run deploy:supabase- Links the project — connects to your remote Supabase project (if not already linked)
- Pushes migrations — applies all pending SQL migrations from
supabase/migrations/to the remote database viasupabase db push - Deploys edge functions — deploys all functions from
supabase/functions/(newsletter-signup, newsletter-admin, newsletter-view, etc.)
To build the static site and deploy Supabase in sequence:
npx github:platform-kit/platformkit build . && npx github:platform-kit/platformkit deploy --project-ref <your-project-ref>Or, if you're developing locally in a cloned repo:
npm run build && npm run deploy:supabaseEdge functions need SMTP credentials and other secrets. Push them to your remote project:
npx supabase secrets set SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=you@example.com SMTP_PASS=secret SMTP_FROM=noreply@example.com
npx supabase secrets set NEWSLETTER_HMAC_SECRET=your-random-secret
npx supabase secrets set CMS_PASSWORD_HASH=your-bcrypt-hashOr use the helper script if available:
npm run supabase:secretsLayouts can contribute custom settings and CMS pages through the manifest system. Each layout exports a manifest from src/layouts/<name>/manifest.ts:
import type { LayoutManifest } from "../../lib/layout-manifest";
export default {
name: "My Layout",
vars: [], // CSS variable overrides (shown in Theme panel)
schema: [], // FormKit schema for inline settings (optional)
cmsTabs: [], // Additional CMS tabs (optional)
} satisfies LayoutManifest;| Need | Manifest field | Renders in | Author writes |
|---|---|---|---|
| Simple theme settings | schema |
Theme panel (inline) | FormKit JSON schema |
| Full CMS tab, simple UI | cmsTabs[].schema |
Own top-level tab | FormKit JSON schema |
| Full CMS tab, complex UI | cmsTabs[].component |
Own top-level tab | Custom Vue component |
| Custom page with URL | routes[] |
Full page (replaces default content) | Vue component + path |
The schema field on the manifest uses FormKit schema to auto-render form inputs in the Theme panel. Available input types include all FormKit inputs plus the custom colorpicker type.
export default {
name: "Bento",
vars: [],
schema: [
{ $formkit: "range", name: "gap", label: "Card Gap (px)", min: 4, max: 24, value: 8 },
{ $formkit: "colorpicker", name: "cardAccent", label: "Card Accent", value: "#3b82f6" },
{ $formkit: "toggle", name: "showLabels", label: "Show card labels", value: true },
],
} satisfies LayoutManifest;Schema-driven settings read and write to the root of layoutData (stored per-layout in theme.layoutData).
Layouts can add full top-level CMS tabs alongside the built-in tabs (Site, Content, Newsletter, Analytics). Each tab's data is sub-keyed at layoutData[tab.key], preventing collisions.
Schema-driven tab (no Vue component needed):
cmsTabs: [
{
key: "integrations",
label: "Integrations",
icon: "pi-link",
schema: [
{ $formkit: "url", name: "spotifyPlaylist", label: "Spotify Playlist URL" },
{ $formkit: "toggle", name: "showGithubActivity", label: "Show activity graph", value: false },
],
},
],Component-driven tab (full custom UI):
cmsTabs: [
{
key: "grid",
label: "Grid Editor",
icon: "pi-th-large",
component: () => import("./BentoGridEditor.vue"),
},
],Component-driven tabs receive modelValue (layoutData[tab.key]) and emit update:modelValue.
Layouts can contribute full pages with their own URL paths. Routes are registered with Vue Router when the layout is active and automatically removed when the user switches to a different layout.
// manifest.ts
export default {
name: "Portfolio",
vars: [],
routes: [
{
path: "/projects",
component: () => import("./ProjectsPage.vue"),
label: "Projects",
icon: "pi-briefcase",
prerender: {
title: "Projects — My Portfolio",
description: "A showcase of my recent work.",
ogImage: "/uploads/projects-og.jpg",
},
},
{
path: "/projects/:slug",
component: () => import("./ProjectDetail.vue"),
},
],
} satisfies LayoutManifest;Route components receive two props automatically:
model— the fullBioModellayoutData—model.theme.layoutData(convenience shortcut)
When a layout route is active, the default page content (profile header, tabs, sections) is hidden and the route component renders in its place. Navigation can be wired via standard <router-link> or programmatic router.push().
The label and icon fields are surfaced in layoutRoutes (available in the template) so nav components can render links to layout-contributed pages.
By default, layout routes are client-side only. Adding a prerender object to a route tells the build to generate a static HTML shell at dist/{path}/index.html with baked-in OG meta tags. This ensures social crawlers (iMessage, Twitter, Facebook) and SEO bots can read the page metadata without executing JavaScript.
prerender: {
title: "Projects — My Portfolio",
description: "A showcase of my recent work.",
ogImage: "/uploads/projects-og.jpg", // absolute or root-relative
},Routes with dynamic params (e.g. /projects/:slug) are skipped — only static paths are pre-rendered. The actual page content is still rendered client-side by Vue.
Both the root schema and individual cmsTabs entries support an optional validation field accepting a Zod schema:
import { z } from "zod";
export default {
name: "Bento",
vars: [],
schema: [
{ $formkit: "range", name: "gap", label: "Gap", min: 4, max: 24, value: 8 },
],
validation: z.object({
gap: z.number().min(4).max(24),
}),
} satisfies LayoutManifest;The vars array registers layout-specific CSS custom properties that are editable in the Theme panel and persisted in theme.layoutVars:
vars: [
{
cssVar: "--bento-card-radius",
label: "Card Radius",
type: "text",
defaultLight: "1rem",
defaultDark: "1rem",
},
],Custom FormKit inputs are registered in src/lib/formkit-config.ts. Currently available:
colorpicker— color swatch + hex text input
To add more, follow the FormKit custom input guide and register in the config file.
Users can ship custom layouts and component overrides as part of their content repo. These are staged into the core app at build time and kept completely isolated from the core codebase.
my-content/
data.json
uploads/
blog/
layouts/ ← custom layouts (optional)
bento/
manifest.ts ← layout manifest (name, schema, cmsTabs, vars)
ProfileHeader.vue ← component overrides for this layout
LinksSection.vue
BentoGridEditor.vue
overrides/ ← global component overrides (optional)
ProfileHeader.vue ← overrides ProfileHeader in ALL layouts
- At build time, the CLI copies
layouts/→src/layouts/user/andoverrides/→src/overrides/user/ - Vite's
import.meta.globdiscovers the files alongside core layouts - Layout names are namespaced as
user/<name>(e.g.user/bento) — no collision with built-in layouts - After the build, staged files are cleaned up — the core repo is never contaminated
.gitignoreexcludessrc/layouts/user/andsrc/overrides/user/
When rendering a component, the resolver checks in order:
- User overrides —
src/overrides/user/<Name>.vue(from content repooverrides/) - Core overrides —
src/overrides/<Name>.vue - Layout variant —
src/layouts/<layout>/<Name>.vue(works for bothminimalanduser/bento) - Fallback — default component from
src/components/
User overrides apply globally across all layouts. Layout-specific overrides only apply when that layout is active.
A minimal user layout needs only a manifest.ts:
// my-content/layouts/bento/manifest.ts
import type { LayoutManifest } from "../../lib/layout-manifest";
export default {
name: "Bento",
vars: [],
schema: [
{ $formkit: "range", name: "columns", label: "Columns", min: 2, max: 6, value: 4 },
{ $formkit: "toggle", name: "showLabels", label: "Show labels", value: true },
],
} satisfies LayoutManifest;Add Vue components alongside the manifest to override specific sections (e.g. ProfileHeader.vue, LinksSection.vue). Any component not overridden falls back to the default.
npx platformkit build ./my-contentThe CLI detects layouts/ and overrides/ in the content directory and stages them automatically. No extra flags needed.
User-provided themes can depend on npm packages not included in the core app. The content repo ships its own package.json and dependencies are resolved through a Vite plugin at build time.
Content repo structure:
my-content/
data.json
package.json ← declares theme-specific dependencies
layouts/
bento/
manifest.ts
BentoGridEditor.vue
node_modules/ ← installed automatically during build
Example package.json:
{
"name": "my-platformkit-content",
"private": true,
"dependencies": {
"chart.js": "^4.0.0",
"vue-chartjs": "^5.3.0"
}
}How it works:
- During
platformkit build ./my-content, the CLI detectspackage.jsonin the content directory npm install --productionruns inside the content directory- A
.user-deps.jsonmarker file is written to the project root with the path to the content repo'snode_modules - A Vite plugin (
user-deps) intercepts bare imports that exist in the content repo'snode_modulesand resolves them from there server.fs.allowis extended so Vite's dev server can serve files from the externalnode_modules- After the build completes, the
.user-deps.jsonmarker is cleaned up
Using dependencies in layout components:
<!-- my-content/layouts/bento/StatsCard.vue -->
<script lang="ts">
import { Bar } from "vue-chartjs"; // resolved from content's node_modules
</script>Documenting dependencies in the manifest:
The peerDependencies field on the manifest is purely documentary — it tells users which packages the theme needs. The actual install is driven by the content repo's package.json.
export default {
name: "Bento",
peerDependencies: {
"chart.js": "^4.0.0",
"vue-chartjs": "^5.3.0",
},
vars: [],
schema: [],
} satisfies LayoutManifest;When running npm run import, set GITHUB_CONTENT_DIR to the local content checkout path to trigger the same dependency install step.
- Vue 3 — reactive UI framework
- PrimeVue — component library (buttons, dialogs, inputs)
- Tailwind CSS — utility-first styling
- Vite — build tool and dev server
- TypeScript — type safety throughout
MIT