Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
track `packages/framework`'s own `package.json`.

## [Unreleased]

### Added

- **`loadCollection(dir, parse?)`** in `@inkform/framework/content` loads any
folder of MDX (`content/<dir>/*.mdx`, sorted by file name) for collections
beyond blog and changelog: podcast episodes, course notes, case studies.
Files starting with `_` are skipped, so examples and drafts can sit next to
real entries. Pass `parse`, for example a zod schema's `.parse`, to type and
validate the frontmatter. When it throws, the error names the file, so a bad
field fails the build with a pointer to it.
- **`@inkform/framework/prose.css`**: MDX content styles only (`.fw-prose`,
callouts, cards, steps, tabs, subscribe, mermaid) for sites that bring their
own layout and design system. It has no global reset, so it never restyles the
host's `html`, `body` or bare elements.

### Fixed

- **Search index on Vercel with Next.js 16.3.8.** Built with a deployment
adapter, Next.js 16.3.8 writes prerendered HTML to `.next/server/route-cache/`
instead of `.next/server/app/`, so `pagefind --site .next/server/app` found
nothing and failed the build. Templates and examples now run
`node scripts/pagefind.mjs`, which reads both layouts and indexes each page
under its real URL (`/docs/intro`, not `/docs/intro.html`). Existing sites:
copy `templates/canopy/scripts/pagefind.mjs` and point `postbuild` at it.

### Changed

- Templates and examples pin `next` 16.3.8 (security release), `vitest` is on
4.x, and the `sharp`, `vite` and `@modelcontextprotocol/sdk` floors are raised
to clear new npm audit advisories.
- The global reset moved out of `styles/tokens.css` into `styles/base.css`.
`styles.css` imports both in the same order as before, so its output is
unchanged.

## [0.6.1] — 2026-09-27

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ a database, a build service, or any external API to render.

## Search indexing

Every theme runs Pagefind as a `postbuild` step
(`pagefind --site .next/server/app --output-path public/_pagefind`), so
Every theme runs Pagefind as a `postbuild` step (`node scripts/pagefind.mjs`,
which indexes the prerendered HTML into `public/_pagefind`), so
Cmd/Ctrl+K search works out of the box on the very first production build —
no separate indexing service, no third-party search SaaS.
2 changes: 1 addition & 1 deletion examples/inkform-docs/content/docs/guides/docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ by default.

Cmd/Ctrl+K search works out of the box via
[Pagefind](https://pagefind.app/), indexed at build time
(`postbuild: pagefind --site .next/server/app --output-path public/_pagefind`).
(`postbuild: node scripts/pagefind.mjs`).
No third-party search service, no API keys, no separate indexing step to
remember.

Expand Down
12 changes: 6 additions & 6 deletions examples/inkform-docs/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@inkform/framework'],
images: { remotePatterns: [{ protocol: 'https', hostname: '**' }] },
// Pagefind indexes .next/server/app's built .html files directly, so its
// search results carry a trailing .html this app doesn't actually serve
// (e.g. /essentials/markdown.html) — this app's real routes are
// extensionless. Redirect (not rewrite) so the address bar ends up on the
// real, shareable permalink; query params (?q=... for highlighting) are
// preserved automatically.
// Search indexes built with `pagefind --site .next/server/app` linked to
// each page's .html file (e.g. /essentials/markdown.html), which this app
// doesn't serve — its real routes are extensionless. scripts/pagefind.mjs
// now indexes the real URLs; this redirect keeps old links working.
// Redirect (not rewrite) so the address bar ends up on the real, shareable
// permalink; query params (?q=... for highlighting) are preserved.
async redirects() {
return [{ source: '/:path*.html', destination: '/:path*', permanent: false }];
},
Expand Down
4 changes: 2 additions & 2 deletions examples/inkform-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "pagefind --site .next/server/app --output-path public/_pagefind",
"postbuild": "node scripts/pagefind.mjs",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@inkform/framework": "^0.6.0",
"lucide-react": "^0.483.0",
"mermaid": "^12.0.0",
"next": "16.3.6",
"next": "16.3.8",
"react": "19.2.1",
"react-dom": "19.2.1"
},
Expand Down
79 changes: 79 additions & 0 deletions examples/inkform-docs/scripts/pagefind.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Postbuild: builds the Pagefind search index from this app's prerendered HTML.
*
* A plain `next build` writes static pages to .next/server/app/<route>.html.
* Built with a deployment adapter (Vercel uses one, and from Next.js 16.3.8 it
* changes the layout), the same pages land in
* .next/server/route-cache/APP_PAGE/<hash>/$/<route>.html instead, and
* `pagefind --site .next/server/app` finds nothing and fails the build. This
* reads both layouts and hands Pagefind each page's real URL (/docs/intro, not
* /docs/intro.html), using Pagefind's Node API.
*/
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import * as pagefind from 'pagefind';

const SERVER_DIR = path.join('.next', 'server');
const OUTPUT_PATH = path.join('public', '_pagefind');
// Next's own error pages are not search results.
const SKIP = new Set(['/_not-found', '/_global-error']);

async function htmlFiles(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true, recursive: true });
} catch {
return [];
}
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.html'))
.map((entry) => path.join(entry.parentPath, entry.name));
}

/** "docs/intro.html" → "/docs/intro"; "index.html" → "/". */
function toUrl(relativeHtmlPath) {
const route = `/${relativeHtmlPath.split(path.sep).join('/').replace(/\.html$/, '')}`;
return route === '/index' ? '/' : route;
}

const pages = new Map();

// Plain build: .next/server/app/<route>.html
const appDir = path.join(SERVER_DIR, 'app');
for (const file of await htmlFiles(appDir)) {
pages.set(toUrl(path.relative(appDir, file)), file);
}

// Adapter build: .next/server/route-cache/APP_PAGE/<hash>/$/<route>.html
const routeCacheDir = path.join(SERVER_DIR, 'route-cache', 'APP_PAGE');
for (const file of await htmlFiles(routeCacheDir)) {
const [, marker, ...route] = path.relative(routeCacheDir, file).split(path.sep);
if (marker === '$' && route.length) pages.set(toUrl(route.join(path.sep)), file);
}

for (const url of SKIP) pages.delete(url);

if (pages.size === 0) {
console.error(`[pagefind] No prerendered HTML found under ${SERVER_DIR}. Run \`next build\` first.`);
process.exit(1);
}

const fail = (step, errors) => {
console.error(`[pagefind] ${step} failed:\n${errors.join('\n')}`);
process.exit(1);
};

const { index, errors: createErrors } = await pagefind.createIndex();
if (!index) fail('createIndex', createErrors);

for (const [url, file] of pages) {
const { errors } = await index.addHTMLFile({ url, content: await readFile(file, 'utf8') });
if (errors.length) fail(`Indexing ${url}`, errors);
}

const { errors: writeErrors } = await index.writeFiles({ outputPath: OUTPUT_PATH });
if (writeErrors.length) fail('writeFiles', writeErrors);
await pagefind.close();

console.log(`[pagefind] Indexed ${pages.size} pages into ${OUTPUT_PATH}`);
12 changes: 6 additions & 6 deletions examples/markdown-docs/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@inkform/framework'],
images: { remotePatterns: [{ protocol: 'https', hostname: '**' }] },
// Pagefind indexes .next/server/app's built .html files directly, so its
// search results carry a trailing .html this app doesn't actually serve
// (e.g. /essentials/markdown.html) — this app's real routes are
// extensionless. Redirect (not rewrite) so the address bar ends up on the
// real, shareable permalink; query params (?q=... for highlighting) are
// preserved automatically.
// Search indexes built with `pagefind --site .next/server/app` linked to
// each page's .html file (e.g. /essentials/markdown.html), which this app
// doesn't serve — its real routes are extensionless. scripts/pagefind.mjs
// now indexes the real URLs; this redirect keeps old links working.
// Redirect (not rewrite) so the address bar ends up on the real, shareable
// permalink; query params (?q=... for highlighting) are preserved.
async redirects() {
return [{ source: '/:path*.html', destination: '/:path*', permanent: false }];
},
Expand Down
4 changes: 2 additions & 2 deletions examples/markdown-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "pagefind --site .next/server/app --output-path public/_pagefind",
"postbuild": "node scripts/pagefind.mjs",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@inkform/framework": "^0.6.0",
"lucide-react": "^0.483.0",
"next": "16.3.6",
"next": "16.3.8",
"react": "19.2.1",
"react-dom": "19.2.1"
},
Expand Down
79 changes: 79 additions & 0 deletions examples/markdown-docs/scripts/pagefind.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Postbuild: builds the Pagefind search index from this app's prerendered HTML.
*
* A plain `next build` writes static pages to .next/server/app/<route>.html.
* Built with a deployment adapter (Vercel uses one, and from Next.js 16.3.8 it
* changes the layout), the same pages land in
* .next/server/route-cache/APP_PAGE/<hash>/$/<route>.html instead, and
* `pagefind --site .next/server/app` finds nothing and fails the build. This
* reads both layouts and hands Pagefind each page's real URL (/docs/intro, not
* /docs/intro.html), using Pagefind's Node API.
*/
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import * as pagefind from 'pagefind';

const SERVER_DIR = path.join('.next', 'server');
const OUTPUT_PATH = path.join('public', '_pagefind');
// Next's own error pages are not search results.
const SKIP = new Set(['/_not-found', '/_global-error']);

async function htmlFiles(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true, recursive: true });
} catch {
return [];
}
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.html'))
.map((entry) => path.join(entry.parentPath, entry.name));
}

/** "docs/intro.html" → "/docs/intro"; "index.html" → "/". */
function toUrl(relativeHtmlPath) {
const route = `/${relativeHtmlPath.split(path.sep).join('/').replace(/\.html$/, '')}`;
return route === '/index' ? '/' : route;
}

const pages = new Map();

// Plain build: .next/server/app/<route>.html
const appDir = path.join(SERVER_DIR, 'app');
for (const file of await htmlFiles(appDir)) {
pages.set(toUrl(path.relative(appDir, file)), file);
}

// Adapter build: .next/server/route-cache/APP_PAGE/<hash>/$/<route>.html
const routeCacheDir = path.join(SERVER_DIR, 'route-cache', 'APP_PAGE');
for (const file of await htmlFiles(routeCacheDir)) {
const [, marker, ...route] = path.relative(routeCacheDir, file).split(path.sep);
if (marker === '$' && route.length) pages.set(toUrl(route.join(path.sep)), file);
}

for (const url of SKIP) pages.delete(url);

if (pages.size === 0) {
console.error(`[pagefind] No prerendered HTML found under ${SERVER_DIR}. Run \`next build\` first.`);
process.exit(1);
}

const fail = (step, errors) => {
console.error(`[pagefind] ${step} failed:\n${errors.join('\n')}`);
process.exit(1);
};

const { index, errors: createErrors } = await pagefind.createIndex();
if (!index) fail('createIndex', createErrors);

for (const [url, file] of pages) {
const { errors } = await index.addHTMLFile({ url, content: await readFile(file, 'utf8') });
if (errors.length) fail(`Indexing ${url}`, errors);
}

const { errors: writeErrors } = await index.writeFiles({ outputPath: OUTPUT_PATH });
if (writeErrors.length) fail('writeFiles', writeErrors);
await pagefind.close();

console.log(`[pagefind] Indexed ${pages.size} pages into ${OUTPUT_PATH}`);
12 changes: 6 additions & 6 deletions examples/pokeapi-docs/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@inkform/framework'],
images: { remotePatterns: [{ protocol: 'https', hostname: '**' }] },
// Pagefind indexes .next/server/app's built .html files directly, so its
// search results carry a trailing .html this app doesn't actually serve
// (e.g. /essentials/markdown.html) — this app's real routes are
// extensionless. Redirect (not rewrite) so the address bar ends up on the
// real, shareable permalink; query params (?q=... for highlighting) are
// preserved automatically.
// Search indexes built with `pagefind --site .next/server/app` linked to
// each page's .html file (e.g. /essentials/markdown.html), which this app
// doesn't serve — its real routes are extensionless. scripts/pagefind.mjs
// now indexes the real URLs; this redirect keeps old links working.
// Redirect (not rewrite) so the address bar ends up on the real, shareable
// permalink; query params (?q=... for highlighting) are preserved.
async redirects() {
return [{ source: '/:path*.html', destination: '/:path*', permanent: false }];
},
Expand Down
4 changes: 2 additions & 2 deletions examples/pokeapi-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "pagefind --site .next/server/app --output-path public/_pagefind",
"postbuild": "node scripts/pagefind.mjs",
"start": "next start",
"typecheck": "tsc --noEmit",
"test:e2e": "playwright test"
},
"dependencies": {
"@inkform/framework": "^0.6.0",
"lucide-react": "^0.483.0",
"next": "16.3.6",
"next": "16.3.8",
"react": "19.2.1",
"react-dom": "19.2.1"
},
Expand Down
Loading
Loading