mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 23:49:51 +00:00
Press: Implement hot-reload & caching layer
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumapress': patch
|
||||
---
|
||||
|
||||
Implement hot-reload & caching layer
|
||||
@@ -33,14 +33,19 @@
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.10",
|
||||
"@hono/node-ws": "^1.3.0",
|
||||
"@takumi-rs/image-response": "^0.69.5",
|
||||
"chokidar": "^5.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||
"hono": "^4.12.4",
|
||||
"lucide-react": "^0.575.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-server-dom-webpack": "^19.2.4",
|
||||
@@ -51,7 +56,6 @@
|
||||
"tinyglobby": "^0.2.15",
|
||||
"unrun": "^0.2.28",
|
||||
"vfile": "^6.0.3",
|
||||
"vite": "^7.3.1",
|
||||
"waku": "1.0.0-alpha.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -61,6 +65,7 @@
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/picomatch": "^4.0.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/unist": "^3.0.3",
|
||||
@@ -69,6 +74,7 @@
|
||||
"tsdown": "0.20.3",
|
||||
"typescript": "^5.9.3",
|
||||
"unified": "^11.0.5",
|
||||
"vite": "^7.3.1",
|
||||
"vite-tsconfig-paths": "^6.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ export async function runStart(flags: { host?: string; port?: string }) {
|
||||
}
|
||||
process.env.PORT = String(port);
|
||||
process.env.PROJECT_DIR = process.cwd();
|
||||
process.env.HOT_RELOAD = '1';
|
||||
process.chdir(baseDir);
|
||||
await import(serveFileUrl);
|
||||
|
||||
console.log(`ready: Listening on http://${host || 'localhost'}:${port}/`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { program } from 'commander';
|
||||
import { runStart } from './utils.js';
|
||||
import { runStart } from './commands.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import { findConfigPath } from '@/config/load-node.js';
|
||||
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as vite from 'vite';
|
||||
import { defineConfig, type FumapressConfig } from '@/config/global';
|
||||
import { checkConfig, findConfigPath } from '@/config/load-node';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export function loadEnv() {
|
||||
dotenv.config({ path: ['.env.local', '.env'], quiet: true });
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<FumapressConfig> {
|
||||
const configPath = await findConfigPath();
|
||||
if (configPath !== null) {
|
||||
const imported = await vite.runnerImport<{ default: FumapressConfig }>(
|
||||
pathToFileURL(configPath).href,
|
||||
);
|
||||
return checkConfig(configPath, imported.module.default);
|
||||
}
|
||||
|
||||
return defineConfig();
|
||||
}
|
||||
|
||||
export function overrideNodeEnv(nodeEnv: 'development' | 'production') {
|
||||
// set NODE_ENV before runnerImport https://github.com/vitejs/vite/issues/20299
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV !== nodeEnv) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
import { decodeEvent } from '@/lib/waku/hot-reload';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'waku';
|
||||
|
||||
export function HotReload() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const url = new URL('/_ws', window.location.href);
|
||||
url.protocol = url.protocol === 'https' ? 'wss' : 'ws';
|
||||
const socket = new WebSocket(url);
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const decoded = decodeEvent(event.data);
|
||||
if (!decoded) return;
|
||||
|
||||
console.log('Received:', decoded);
|
||||
if (decoded.type === 'revalidate') {
|
||||
void router.reload();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
@@ -8,7 +8,14 @@ export interface RevalidableConfig<Args extends unknown[], O> {
|
||||
create: (...args: Args) => O;
|
||||
}
|
||||
|
||||
export function revalidable<Args extends unknown[], O>(config: RevalidableConfig<Args, O>) {
|
||||
export type WithRevalidate<Args extends unknown[], O> = {
|
||||
revalidate: (keepStale?: boolean) => void;
|
||||
(...args: Args): O;
|
||||
};
|
||||
|
||||
export function revalidable<Args extends unknown[], O>(
|
||||
config: RevalidableConfig<Args, O>,
|
||||
): WithRevalidate<Args, O> {
|
||||
const { create, staleTime } = config;
|
||||
|
||||
if (typeof staleTime === 'number') {
|
||||
@@ -16,7 +23,7 @@ export function revalidable<Args extends unknown[], O>(config: RevalidableConfig
|
||||
let lastResult: O = undefined as O;
|
||||
let revalidating = false;
|
||||
|
||||
return function (...args: Args) {
|
||||
const out: WithRevalidate<Args, O> = function (...args: Args) {
|
||||
if (lastValidated === null) {
|
||||
// init
|
||||
lastResult = create(...args);
|
||||
@@ -46,7 +53,15 @@ export function revalidable<Args extends unknown[], O>(config: RevalidableConfig
|
||||
|
||||
return lastResult;
|
||||
};
|
||||
|
||||
out.revalidate = (keepStale = true) => {
|
||||
lastValidated = keepStale ? 0 : null;
|
||||
};
|
||||
}
|
||||
|
||||
return cache(create);
|
||||
const out = cache(create) as WithRevalidate<Args, O>;
|
||||
out.revalidate = () => {
|
||||
return;
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { InferPageType, loader, source } from 'fumadocs-core/source';
|
||||
import { glob } from 'tinyglobby';
|
||||
import grayMatter from 'gray-matter';
|
||||
import fs from 'node:fs/promises';
|
||||
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
|
||||
import { getConfigRuntime } from '../config/load-runtime';
|
||||
import path from 'node:path';
|
||||
import { revalidable } from './revalidable';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import type { ContentConfig, ProjectConfig } from '@/config/global';
|
||||
import { normalizeProjects } from './source/config';
|
||||
|
||||
export interface RawPage {
|
||||
type: 'page';
|
||||
path: string;
|
||||
absolutePath: string;
|
||||
|
||||
data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
|
||||
project: ProjectConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RawMeta {
|
||||
type: 'meta';
|
||||
path: string;
|
||||
absolutePath: string;
|
||||
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
} & Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function getPages(config: ContentConfig): Promise<{
|
||||
pages: RawPage[];
|
||||
metas: RawMeta[];
|
||||
}> {
|
||||
async function md(project: ProjectConfig, file: string): Promise<RawPage> {
|
||||
const absolutePath = path.resolve(project.dir, file);
|
||||
const content = (await fs.readFile(absolutePath)).toString();
|
||||
const parsed = grayMatter({ content });
|
||||
|
||||
// use default frontmatter if invalid in Fumadocs' spec
|
||||
const { data: frontmatter = {} } = pageSchema.partial().loose().safeParse(parsed.data);
|
||||
|
||||
return {
|
||||
type: 'page',
|
||||
path: file,
|
||||
absolutePath,
|
||||
data: {
|
||||
title: frontmatter.title ?? path.basename(file, path.extname(file)),
|
||||
description: frontmatter.description,
|
||||
content: parsed.content,
|
||||
frontmatter,
|
||||
project,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function json(project: ProjectConfig, file: string): Promise<RawMeta | undefined> {
|
||||
const absolutePath = path.resolve(project.dir, file);
|
||||
const content = (await fs.readFile(absolutePath)).toString();
|
||||
const parsed = JSON.parse(content);
|
||||
const result = metaSchema.loose().safeParse(parsed);
|
||||
|
||||
// ignore if it is not `meta.json` for Fumadocs
|
||||
if (result.error) return;
|
||||
return {
|
||||
type: 'meta',
|
||||
path: file,
|
||||
absolutePath,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
async function project(project: ProjectConfig) {
|
||||
const files = await glob(
|
||||
project.include ?? ['**/*.{md,mdx}', '**/meta.json', '!node_modules'],
|
||||
{
|
||||
cwd: project.dir,
|
||||
},
|
||||
);
|
||||
|
||||
return await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const ext = path.extname(file);
|
||||
|
||||
try {
|
||||
switch (ext) {
|
||||
case '.json':
|
||||
return await json(project, file);
|
||||
case '.mdx':
|
||||
case '.md':
|
||||
return await md(project, file);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`error when parsing ${file}`, e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const projects = normalizeProjects(config.projects ?? [{ dir: '', name: 'root' }]);
|
||||
const all = (await Promise.all(projects.map(project))).flat();
|
||||
|
||||
return {
|
||||
metas: all.filter((item) => item?.type === 'meta'),
|
||||
pages: all.filter((item) => item?.type === 'page'),
|
||||
};
|
||||
}
|
||||
|
||||
export const getSource = revalidable({
|
||||
async create() {
|
||||
const config = await getConfigRuntime();
|
||||
|
||||
return loader({
|
||||
source: source(await getPages(config.content ?? {})),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
baseUrl: '/',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export type SourcePage = InferPageType<Awaited<ReturnType<typeof getSource>>>;
|
||||
|
||||
export function getPageImage(slugs: string[]) {
|
||||
const segments = [...slugs, 'image.webp'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url: `/og/${segments.join('/')}`,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { ProjectConfig } from '@/config';
|
||||
import path from 'node:path';
|
||||
|
||||
export function normalizeProjects(projects: ProjectConfig[]): ProjectConfig[] {
|
||||
export type NormalizedProjectConfig = Required<ProjectConfig>;
|
||||
|
||||
export const DefaultProjects: ProjectConfig[] = [{ dir: '', name: 'root' }];
|
||||
|
||||
export function normalizeProjects(
|
||||
projects: ProjectConfig[] = DefaultProjects,
|
||||
): NormalizedProjectConfig[] {
|
||||
const baseDir = process.env.PROJECT_DIR ?? process.cwd();
|
||||
|
||||
return projects.map((project) => ({
|
||||
...project,
|
||||
include: project.include ?? ['**/*.{md,mdx}', '**/meta.json', '!**/node_modules'],
|
||||
dir: path.resolve(baseDir, project.dir),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type InferPageType, loader, source } from 'fumadocs-core/source';
|
||||
import { getConfigRuntime } from '@/config/load-runtime';
|
||||
import { revalidable } from '@/lib/revalidable';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { getPages } from './storage';
|
||||
|
||||
export const getSource = revalidable({
|
||||
async create() {
|
||||
const config = await getConfigRuntime();
|
||||
|
||||
return loader({
|
||||
source: source(await getPages(config.content ?? {})),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
baseUrl: '/',
|
||||
});
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
export type SourcePage = InferPageType<Awaited<ReturnType<typeof getSource>>>;
|
||||
|
||||
export function getPageImage(slugs: string[]) {
|
||||
const segments = [...slugs, 'image.webp'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url: `/og/${segments.join('/')}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ContentConfig } from '@/config/global';
|
||||
import fs from 'node:fs/promises';
|
||||
import { glob } from 'tinyglobby';
|
||||
import grayMatter from 'gray-matter';
|
||||
import path from 'node:path';
|
||||
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
|
||||
import { type NormalizedProjectConfig, normalizeProjects } from './config';
|
||||
|
||||
export interface RawPage {
|
||||
type: 'page';
|
||||
path: string;
|
||||
absolutePath: string;
|
||||
|
||||
data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
|
||||
project: NormalizedProjectConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RawMeta {
|
||||
type: 'meta';
|
||||
path: string;
|
||||
absolutePath: string;
|
||||
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
} & Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const filesCache = new Map<string, RawPage | RawMeta>();
|
||||
|
||||
async function md(
|
||||
project: NormalizedProjectConfig,
|
||||
absolutePath: string,
|
||||
file: string,
|
||||
): Promise<RawPage> {
|
||||
const content = (await fs.readFile(absolutePath)).toString();
|
||||
const parsed = grayMatter({ content });
|
||||
|
||||
// use default frontmatter if invalid in Fumadocs' spec
|
||||
const { data: frontmatter = {} } = pageSchema.partial().loose().safeParse(parsed.data);
|
||||
|
||||
return {
|
||||
type: 'page',
|
||||
path: file,
|
||||
absolutePath,
|
||||
data: {
|
||||
title: frontmatter.title ?? path.basename(file, path.extname(file)),
|
||||
description: frontmatter.description,
|
||||
content: parsed.content,
|
||||
frontmatter,
|
||||
project,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function json(
|
||||
_project: NormalizedProjectConfig,
|
||||
absolutePath: string,
|
||||
file: string,
|
||||
): Promise<RawMeta | undefined> {
|
||||
const content = (await fs.readFile(absolutePath)).toString();
|
||||
const parsed = JSON.parse(content);
|
||||
const result = metaSchema.loose().safeParse(parsed);
|
||||
|
||||
// ignore if it is not `meta.json` for Fumadocs
|
||||
if (result.error) return;
|
||||
return {
|
||||
type: 'meta',
|
||||
path: file,
|
||||
absolutePath,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPages(config: ContentConfig): Promise<{
|
||||
pages: RawPage[];
|
||||
metas: RawMeta[];
|
||||
}> {
|
||||
async function project(project: NormalizedProjectConfig) {
|
||||
const files = await glob(project.include, {
|
||||
cwd: project.dir,
|
||||
});
|
||||
|
||||
return await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const absolutePath = path.resolve(project.dir, file);
|
||||
const cached = filesCache.get(absolutePath);
|
||||
if (cached) return cached;
|
||||
|
||||
const ext = path.extname(file);
|
||||
|
||||
try {
|
||||
let out: RawMeta | RawPage | undefined;
|
||||
switch (ext) {
|
||||
case '.json':
|
||||
out = await json(project, absolutePath, file);
|
||||
break;
|
||||
case '.mdx':
|
||||
case '.md':
|
||||
out = await md(project, absolutePath, file);
|
||||
break;
|
||||
}
|
||||
|
||||
if (out === undefined) filesCache.delete(absolutePath);
|
||||
else filesCache.set(absolutePath, out);
|
||||
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.error(`error when parsing ${file}`, e);
|
||||
filesCache.delete(absolutePath);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const projects = normalizeProjects(config.projects);
|
||||
const all = (await Promise.all(projects.map(project))).flat();
|
||||
|
||||
return {
|
||||
metas: all.filter((item) => item?.type === 'meta'),
|
||||
pages: all.filter((item) => item?.type === 'page'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FumapressConfig } from '@/config';
|
||||
import { watch } from 'chokidar';
|
||||
import { normalizeProjects } from './config';
|
||||
import matcher from 'picomatch';
|
||||
import path from 'node:path';
|
||||
import type { Stats } from 'node:fs';
|
||||
import processPatterns, { getOptions } from '../tinyglobby/patterns';
|
||||
|
||||
export function startWatcher(config: FumapressConfig) {
|
||||
const projects = normalizeProjects(config.content?.projects);
|
||||
const ignored = projects.map((project) => {
|
||||
const processed = processPatterns(getOptions({ cwd: project.dir }), project.include, {
|
||||
root: project.dir,
|
||||
depthOffset: 0,
|
||||
});
|
||||
const isMatch = matcher(processed.match, {
|
||||
ignore: processed.ignore,
|
||||
});
|
||||
|
||||
return (filePath: string, stats?: Stats): boolean => {
|
||||
if (stats && stats.isFile()) {
|
||||
return !isMatch(path.relative(project.dir, filePath));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
});
|
||||
|
||||
const watcher = watch(
|
||||
projects.map((project) => project.dir),
|
||||
{
|
||||
ignoreInitial: true,
|
||||
followSymlinks: false,
|
||||
ignored,
|
||||
},
|
||||
);
|
||||
|
||||
return watcher;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* from https://github.com/SuperchupuDev/tinyglobby/blob/main/src/patterns.ts */
|
||||
import { isAbsolute, posix } from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
import { escapePath, type GlobOptions, isDynamicPattern } from 'tinyglobby';
|
||||
|
||||
const PARENT_DIRECTORY = /^(\/?\.\.)+/;
|
||||
const ESCAPING_BACKSLASHES = /\\(?=[()[\]{}!*+?@|])/g;
|
||||
|
||||
export interface InternalProps {
|
||||
root: string;
|
||||
commonPath?: string[];
|
||||
depthOffset: number;
|
||||
}
|
||||
|
||||
export type InternalOptions = Pick<
|
||||
Required<GlobOptions>,
|
||||
| 'caseSensitiveMatch'
|
||||
| 'cwd'
|
||||
| 'debug'
|
||||
| 'expandDirectories'
|
||||
| 'followSymbolicLinks'
|
||||
| 'onlyFiles'
|
||||
| 'ignore'
|
||||
> &
|
||||
GlobOptions;
|
||||
|
||||
export interface ProcessedPatterns {
|
||||
match: string[];
|
||||
ignore: string[];
|
||||
}
|
||||
|
||||
function normalizePattern(
|
||||
pattern: string,
|
||||
opts: InternalOptions,
|
||||
props: InternalProps,
|
||||
isIgnore: boolean,
|
||||
) {
|
||||
const cwd = opts.cwd as string;
|
||||
let result: string = pattern;
|
||||
|
||||
if (pattern[pattern.length - 1] === '/') {
|
||||
result = pattern.slice(0, -1);
|
||||
}
|
||||
// using a directory as entry should match all files inside it
|
||||
if (result[result.length - 1] !== '*' && opts.expandDirectories) {
|
||||
result += '/**';
|
||||
}
|
||||
|
||||
const escapedCwd = escapePath(cwd);
|
||||
result = isAbsolute(result.replace(ESCAPING_BACKSLASHES, ''))
|
||||
? posix.relative(escapedCwd, result)
|
||||
: posix.normalize(result);
|
||||
|
||||
const parentDir = PARENT_DIRECTORY.exec(result)?.[0];
|
||||
const parts = splitPattern(result);
|
||||
let i = 0;
|
||||
|
||||
if (parentDir) {
|
||||
const n = (parentDir.length + 1) / 3;
|
||||
|
||||
// normalize a pattern like `../foo/bar` to `bar` when cwd ends with `/foo`
|
||||
const cwdParts = escapedCwd.split('/');
|
||||
while (i < n && parts[i + n] === cwdParts[cwdParts.length + i - n]) {
|
||||
result = `${result.slice(0, (n - i - 1) * 3)}${result.slice((n - i) * 3 + parts[i++ + n]!.length + 1) || '.'}`;
|
||||
}
|
||||
|
||||
// move root `n` directories up
|
||||
const potentialRoot = posix.join(cwd, parentDir.slice(i * 3));
|
||||
// windows can make the potential root something like `../C:`, we don't want that
|
||||
if (potentialRoot[0] !== '.' && props.root.length > potentialRoot.length) {
|
||||
props.root = potentialRoot;
|
||||
props.depthOffset = -n + i;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isIgnore && props.depthOffset >= 0) {
|
||||
props.commonPath ??= parts;
|
||||
|
||||
const newCommonPath: string[] = [];
|
||||
const length = Math.min(props.commonPath.length, parts.length);
|
||||
|
||||
for (i = 0; i < length; i++) {
|
||||
const part = parts[i]!;
|
||||
|
||||
if (part === '**' && !parts[i + 1]) {
|
||||
newCommonPath.pop();
|
||||
break;
|
||||
}
|
||||
|
||||
if (i === parts.length - 1 || part !== props.commonPath[i] || isDynamicPattern(part)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
props.depthOffset = newCommonPath.length;
|
||||
props.commonPath = newCommonPath;
|
||||
|
||||
props.root = newCommonPath.length > 0 ? posix.join(cwd, ...newCommonPath) : cwd;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
//#endregion normalizePattern
|
||||
|
||||
//#region processPatterns
|
||||
export default function processPatterns(
|
||||
options: InternalOptions,
|
||||
patterns: readonly string[],
|
||||
props: InternalProps,
|
||||
): ProcessedPatterns {
|
||||
const matchPatterns: string[] = [];
|
||||
const ignorePatterns: string[] = [];
|
||||
|
||||
for (const pattern of options.ignore) {
|
||||
// don't handle negated patterns here for consistency with fast-glob
|
||||
if (pattern && (pattern[0] !== '!' || pattern[1] === '(')) {
|
||||
ignorePatterns.push(normalizePattern(pattern, options, props, true));
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern) {
|
||||
if (pattern[0] !== '!' || pattern[1] === '(') {
|
||||
matchPatterns.push(normalizePattern(pattern, options, props, false));
|
||||
} else if (pattern[1] !== '!' || pattern[2] === '(') {
|
||||
ignorePatterns.push(normalizePattern(pattern.slice(1), options, props, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { match: matchPatterns, ignore: ignorePatterns };
|
||||
}
|
||||
|
||||
const splitPatternOptions = { parts: true };
|
||||
|
||||
export function splitPattern(path: string): string[] {
|
||||
const result = picomatch.scan(path, splitPatternOptions);
|
||||
return result.parts?.length ? result.parts : [path];
|
||||
}
|
||||
|
||||
const defaultOptions: GlobOptions = {
|
||||
caseSensitiveMatch: true,
|
||||
cwd: process.cwd(),
|
||||
debug: !!process.env.TINYGLOBBY_DEBUG,
|
||||
expandDirectories: true,
|
||||
followSymbolicLinks: true,
|
||||
onlyFiles: true,
|
||||
};
|
||||
|
||||
export function getOptions(options?: GlobOptions): InternalOptions {
|
||||
const opts = { ...defaultOptions, ...options } as InternalOptions;
|
||||
opts.ignore ??= [];
|
||||
return opts;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import path from 'node:path';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { Hono } from 'hono/tiny';
|
||||
import { unstable_createServerEntryAdapter as createServerEntryAdapter } from 'waku/adapter-builders';
|
||||
import {
|
||||
unstable_constants as constants,
|
||||
unstable_honoMiddleware as honoMiddleware,
|
||||
} from 'waku/internals';
|
||||
import type { BuildOptions } from 'waku/adapters/node-build-enhancer';
|
||||
import { createNodeWebSocket } from '@hono/node-ws';
|
||||
import { startWatcher } from '../source/watcher';
|
||||
import { getConfigRuntime } from '@/config/load-runtime';
|
||||
import { type WSContext } from 'hono/ws';
|
||||
import { encodeEvent, WebSocketEvent } from './hot-reload';
|
||||
import { filesCache } from '../source/storage';
|
||||
import { getSource } from '../source';
|
||||
|
||||
const { DIST_PUBLIC } = constants;
|
||||
const { contextMiddleware, rscMiddleware, middlewareRunner } = honoMiddleware;
|
||||
|
||||
export default createServerEntryAdapter(
|
||||
(
|
||||
{ processRequest, processBuild, config, isBuild, notFoundHtml },
|
||||
options?: {
|
||||
middlewareFns?: (() => MiddlewareHandler)[];
|
||||
middlewareModules?: Record<string, () => Promise<unknown>>;
|
||||
},
|
||||
) => {
|
||||
const { middlewareFns = [], middlewareModules = {} } = options || {};
|
||||
const app = new Hono();
|
||||
let serveFn = serve;
|
||||
|
||||
app.notFound((c) => {
|
||||
if (notFoundHtml) {
|
||||
return c.html(notFoundHtml, 404);
|
||||
}
|
||||
return c.text('404 Not Found', 404);
|
||||
});
|
||||
if (isBuild) {
|
||||
app.use(
|
||||
`${config.basePath}*`,
|
||||
serveStatic({
|
||||
root: path.join(config.distDir, DIST_PUBLIC),
|
||||
rewriteRequestPath: (path) => path.slice(config.basePath.length - 1),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (isBuild && process.env.HOT_RELOAD === '1') {
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
const clients = new Set<WSContext>();
|
||||
|
||||
void initHotReload(clients).catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/_ws',
|
||||
upgradeWebSocket(() => ({
|
||||
onOpen(_, ws) {
|
||||
clients.add(ws);
|
||||
},
|
||||
onClose(_, ws) {
|
||||
clients.delete(ws);
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
serveFn = (...args) => {
|
||||
const server = serve(...args);
|
||||
injectWebSocket(server);
|
||||
|
||||
for (const signal of ['SIGTERM', 'SIGINT'])
|
||||
process.once(signal, () => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
return server;
|
||||
};
|
||||
}
|
||||
|
||||
app.use(contextMiddleware() as never);
|
||||
for (const middlewareFn of middlewareFns) {
|
||||
app.use(middlewareFn());
|
||||
}
|
||||
|
||||
app.use(middlewareRunner(middlewareModules as never) as never);
|
||||
app.use(rscMiddleware({ processRequest }) as never);
|
||||
const buildOptions: BuildOptions = {
|
||||
distDir: config.distDir,
|
||||
};
|
||||
|
||||
return {
|
||||
fetch: app.fetch,
|
||||
build: processBuild,
|
||||
buildOptions,
|
||||
buildEnhancers: ['waku/adapters/node-build-enhancer'],
|
||||
serve: serveFn,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
async function initHotReload(clients: Set<WSContext>) {
|
||||
const watcher = startWatcher(await getConfigRuntime());
|
||||
|
||||
function send(event: WebSocketEvent) {
|
||||
const encoded = encodeEvent(event);
|
||||
|
||||
for (const client of clients) client.send(encoded);
|
||||
}
|
||||
|
||||
watcher.on('all', (event, filePath) => {
|
||||
if (event === 'change') {
|
||||
filesCache.delete(filePath);
|
||||
send({
|
||||
type: 'clear-cache',
|
||||
absolutePath: filePath,
|
||||
});
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case 'add':
|
||||
case 'change':
|
||||
case 'unlink':
|
||||
getSource.revalidate(false);
|
||||
send({
|
||||
type: 'revalidate',
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
for (const signal of ['SIGTERM', 'SIGINT'])
|
||||
process.once(signal, () => {
|
||||
watcher.close();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const OnClearCacheSchema = z.object({
|
||||
type: z.literal('clear-cache'),
|
||||
absolutePath: z.string(),
|
||||
});
|
||||
|
||||
const OnRevalidateSchema = z.object({
|
||||
type: z.literal('revalidate'),
|
||||
});
|
||||
|
||||
export const WebSocketEventSchema = z.discriminatedUnion('type', [
|
||||
OnClearCacheSchema,
|
||||
OnRevalidateSchema,
|
||||
]);
|
||||
|
||||
// TypeScript type inferred from the schema (equivalent to your original union)
|
||||
export type WebSocketEvent = z.infer<typeof WebSocketEventSchema>;
|
||||
export type OnClearCache = z.infer<typeof OnClearCacheSchema>;
|
||||
export type OnRevalidate = z.infer<typeof OnRevalidateSchema>;
|
||||
|
||||
export function encodeEvent(event: WebSocketEvent) {
|
||||
return JSON.stringify(event);
|
||||
}
|
||||
|
||||
export function decodeEvent(event: string): WebSocketEvent | undefined {
|
||||
try {
|
||||
return WebSocketEventSchema.safeParse(JSON.parse(event)).data;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Provider } from '@/components/provider';
|
||||
import '@/styles/globals.css';
|
||||
import { HotReload } from '@/components/hot-reload';
|
||||
|
||||
export default async function RootElement({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@@ -8,6 +9,7 @@ export default async function RootElement({ children }: { children: ReactNode })
|
||||
<head></head>
|
||||
<body data-version="1.0" className="flex flex-col min-h-screen">
|
||||
<Provider>{children}</Provider>
|
||||
{process.env.HOT_RELOAD === '1' && <HotReload />}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
Vendored
+8
@@ -2,7 +2,15 @@ declare module 'process' {
|
||||
global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* the project directory (where fumapress is started)
|
||||
*/
|
||||
PROJECT_DIR?: string;
|
||||
|
||||
/**
|
||||
* whether hot reload is enabled (e.g. local file watcher)
|
||||
*/
|
||||
HOT_RELOAD?: '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import type { UserConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
distDir: 'dist/waku',
|
||||
unstable_adapter: './src/lib/waku/adapter.ts',
|
||||
vite: {
|
||||
// we do this to avoid Vite from bundling React contexts and cause duplicated contexts conflicts.
|
||||
optimizeDeps: {
|
||||
exclude: ['fumadocs-ui', 'fumadocs-core'],
|
||||
},
|
||||
ssr: {
|
||||
external: ['@takumi-rs/image-response', 'unrun'],
|
||||
resolve: {
|
||||
external: ['@takumi-rs/image-response', 'unrun', 'chokidar'],
|
||||
},
|
||||
|
||||
plugins: [tailwindcss(), tsconfigPaths()],
|
||||
|
||||
Generated
+57
-17
@@ -2453,9 +2453,18 @@ importers:
|
||||
|
||||
packages/press:
|
||||
dependencies:
|
||||
'@hono/node-server':
|
||||
specifier: ^1.19.10
|
||||
version: 1.19.10([email protected])
|
||||
'@hono/node-ws':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(@hono/[email protected]([email protected]))([email protected])
|
||||
'@takumi-rs/image-response':
|
||||
specifier: ^0.69.5
|
||||
version: 0.69.5
|
||||
chokidar:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
commander:
|
||||
specifier: ^14.0.3
|
||||
version: 14.0.3
|
||||
@@ -2474,9 +2483,15 @@ importers:
|
||||
hast-util-to-jsx-runtime:
|
||||
specifier: ^2.3.6
|
||||
version: 2.3.6
|
||||
hono:
|
||||
specifier: ^4.12.4
|
||||
version: 4.12.4
|
||||
lucide-react:
|
||||
specifier: ^0.575.0
|
||||
version: 0.575.0([email protected])
|
||||
picomatch:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
react:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4
|
||||
@@ -2507,9 +2522,6 @@ importers:
|
||||
vfile:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
|
||||
waku:
|
||||
specifier: 1.0.0-alpha.5
|
||||
version: 1.0.0-alpha.5(@types/[email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])
|
||||
@@ -2532,6 +2544,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^25.3.3
|
||||
version: 25.3.3
|
||||
'@types/picomatch':
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
'@types/react':
|
||||
specifier: ^19.2.14
|
||||
version: 19.2.14
|
||||
@@ -2556,6 +2571,9 @@ importers:
|
||||
unified:
|
||||
specifier: ^11.0.5
|
||||
version: 11.0.5
|
||||
vite:
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
|
||||
vite-tsconfig-paths:
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
@@ -3942,12 +3960,25 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.2.0
|
||||
|
||||
'@hono/[email protected]':
|
||||
resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@hono/[email protected]':
|
||||
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@hono/[email protected]':
|
||||
resolution: {integrity: sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
'@hono/node-server': ^1.19.2
|
||||
hono: ^4.6.0
|
||||
|
||||
'@humanfs/[email protected]':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -9075,14 +9106,14 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-50UEfZq1KR0Lfk2Tr6xb/MUIZH3h10oNC0OTy9g7WELcs5Fgy/mKN1vEhuKTkKbdo8vr5F9GXstu2eLhApfQ3A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==}
|
||||
|
||||
@@ -13527,14 +13558,23 @@ snapshots:
|
||||
'@tanstack/vue-virtual': 3.13.19([email protected]([email protected]))
|
||||
vue: 3.5.29([email protected])
|
||||
|
||||
'@hono/[email protected].9([email protected].2)':
|
||||
'@hono/[email protected].10([email protected].4)':
|
||||
dependencies:
|
||||
hono: 4.12.2
|
||||
hono: 4.12.4
|
||||
|
||||
'@hono/[email protected]([email protected])':
|
||||
dependencies:
|
||||
hono: 4.12.3
|
||||
|
||||
'@hono/[email protected](@hono/[email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.10([email protected])
|
||||
hono: 4.12.4
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@humanfs/[email protected]': {}
|
||||
|
||||
'@humanfs/[email protected]':
|
||||
@@ -13832,7 +13872,7 @@ snapshots:
|
||||
|
||||
'@modelcontextprotocol/[email protected]([email protected])':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9([email protected].2)
|
||||
'@hono/node-server': 1.19.9([email protected].3)
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1([email protected])
|
||||
content-type: 1.0.5
|
||||
@@ -13842,7 +13882,7 @@ snapshots:
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.2.1([email protected])
|
||||
hono: 4.12.2
|
||||
hono: 4.12.3
|
||||
jose: 6.1.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
@@ -13854,7 +13894,7 @@ snapshots:
|
||||
|
||||
'@modelcontextprotocol/[email protected]([email protected])':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9([email protected].2)
|
||||
'@hono/node-server': 1.19.9([email protected].3)
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1([email protected])
|
||||
content-type: 1.0.5
|
||||
@@ -13864,7 +13904,7 @@ snapshots:
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.2.1([email protected])
|
||||
hono: 4.12.2
|
||||
hono: 4.12.3
|
||||
jose: 6.1.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
@@ -19228,10 +19268,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -22925,11 +22965,11 @@ snapshots:
|
||||
|
||||
[email protected](@types/[email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9([email protected].3)
|
||||
'@hono/node-server': 1.19.10([email protected].4)
|
||||
'@vitejs/plugin-react': 5.1.4([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
'@vitejs/plugin-rsc': 0.5.21([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
dotenv: 17.3.1
|
||||
hono: 4.12.3
|
||||
hono: 4.12.4
|
||||
magic-string: 0.30.21
|
||||
picocolors: 1.1.1
|
||||
react: 19.2.4
|
||||
|
||||
Reference in New Issue
Block a user