diff --git a/.docs/site/.gitignore b/.docs/site/.gitignore new file mode 100644 index 0000000..7ab6091 --- /dev/null +++ b/.docs/site/.gitignore @@ -0,0 +1,6 @@ +node_modules +.env* +!.env.example +.next +.vercel +app/docs diff --git a/.docs/site/app/global.css b/.docs/site/app/global.css new file mode 100644 index 0000000..3c489ba --- /dev/null +++ b/.docs/site/app/global.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "@farming-labs/theme/colorful/css"; diff --git a/.docs/site/app/layout.tsx b/.docs/site/app/layout.tsx new file mode 100644 index 0000000..8a31ca5 --- /dev/null +++ b/.docs/site/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { RootProvider } from "@farming-labs/theme"; +import docsConfig from "../docs.config"; +import "./global.css"; + +const geistSans = Geist({ + variable: "--fd-font-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--fd-font-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: { + default: "Docs", + template: docsConfig.metadata?.titleTemplate ?? "%s", + }, + description: docsConfig.metadata?.description, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/.docs/site/app/page.tsx b/.docs/site/app/page.tsx new file mode 100644 index 0000000..6f40eba --- /dev/null +++ b/.docs/site/app/page.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; + +const title = "Befter"; +const description = "A lightweight JavaScript library for managing hooks — register, invoke, update, and sequence them with full control over execution order."; + +export default function HomePage() { + return ( +
+
+
+

+ Documentation +

+

{title}

+

{description}

+

+ Author markdown in{" "} + + docs/ + + . Everything under{" "} + /docs is synced from that folder. +

+
+ + Open documentation + +
+
+
+ ); +} diff --git a/.docs/site/docs.config.tsx b/.docs/site/docs.config.tsx new file mode 100644 index 0000000..e4fe3b4 --- /dev/null +++ b/.docs/site/docs.config.tsx @@ -0,0 +1,19 @@ +import { defineDocs } from "@farming-labs/docs"; +import { colorful } from "@farming-labs/theme/colorful"; + +export default defineDocs({ + entry: "docs", + theme: colorful(), + ordering: [ + { + "slug": "quickstart" + }, + { + "slug": "installation" + } + ], + metadata: { + titleTemplate: "%s – Docs", + description: "Managed by @farming-labs/docs Cloud", + }, +}); diff --git a/.docs/site/next-env.d.ts b/.docs/site/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/.docs/site/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/.docs/site/next.config.ts b/.docs/site/next.config.ts new file mode 100644 index 0000000..948bed4 --- /dev/null +++ b/.docs/site/next.config.ts @@ -0,0 +1,12 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { withDocs } from "@farming-labs/next/config"; + +const appDir = dirname(fileURLToPath(import.meta.url)); +const root = join(appDir, "../.."); + +export default withDocs({ + turbopack: { + root, + }, +}); diff --git a/.docs/site/package.json b/.docs/site/package.json new file mode 100644 index 0000000..a3012c4 --- /dev/null +++ b/.docs/site/package.json @@ -0,0 +1,30 @@ +{ + "name": "docs-cloud-managed-runtime", + "private": true, + "packageManager": "pnpm@10.9.0", + "scripts": { + "sync:content": "node ./scripts/sync-managed-content.mjs", + "dev": "node ./scripts/sync-managed-content.mjs && next dev --turbopack", + "build": "node ./scripts/sync-managed-content.mjs && next build --turbopack", + "start": "node ./scripts/sync-managed-content.mjs && next start" + }, + "dependencies": { + "@farming-labs/docs": "latest", + "@farming-labs/next": "latest", + "@farming-labs/theme": "latest", + "next": "16.2.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.1.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.18", + "@types/mdx": "^2.0.13", + "@types/node": "^22.10.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3" + } +} diff --git a/.docs/site/postcss.config.mjs b/.docs/site/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/.docs/site/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/.docs/site/scripts/sync-managed-content.mjs b/.docs/site/scripts/sync-managed-content.mjs new file mode 100644 index 0000000..bd8e35b --- /dev/null +++ b/.docs/site/scripts/sync-managed-content.mjs @@ -0,0 +1,159 @@ +import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, extname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const runtimeRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const repoRoot = resolve(runtimeRoot, "../.."); +const authoredRoots = [ + { source: resolve(repoRoot, "docs"), target: resolve(runtimeRoot, "app/docs") }, + { source: resolve(repoRoot, "api-reference"), target: resolve(runtimeRoot, "app/docs/api") }, +]; +const ignoredDirectoryNames = new Set([ + "node_modules", + ".next", + ".turbo", + ".vercel", + "dist", + "build", + "coverage", +]); +const ignoredFileNames = new Set([ + "bun.lock", + "jsconfig.json", + "package-lock.json", + "package.json", + "pnpm-lock.yaml", + "tsconfig.json", + "yarn.lock", +]); +const staticAssetExtensions = new Set([ + ".avif", + ".bmp", + ".csv", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".json", + ".mp4", + ".pdf", + ".png", + ".svg", + ".txt", + ".webm", + ".webp", + ".zip", +]); +const codeFenceLanguageAliases = new Map([ + ["dotenv", "bash"], + ["env", "bash"], + ["shell", "bash"], +]); + +function isMarkdownFile(path) { + return [".md", ".mdx"].includes(extname(path).toLowerCase()); +} + +function isStaticAssetFile(path) { + return staticAssetExtensions.has(extname(path).toLowerCase()); +} + +function shouldSkipDirectory(name) { + return name.startsWith(".") || ignoredDirectoryNames.has(name); +} + +function shouldSkipFile(name) { + return name.startsWith(".") || ignoredFileNames.has(name); +} + +function normalizeMarkdownContent(content) { + const fenceMarker = String.fromCharCode(96); + const codeFencePattern = new RegExp( + "(^|\\n)(" + fenceMarker + "{3,})([A-Za-z0-9_+.-]+)([^\\n" + fenceMarker + "]*)", + "g", + ); + + return content.replace(codeFencePattern, (match, prefix, fence, language, rest = "") => { + const normalizedLanguage = codeFenceLanguageAliases.get(language.toLowerCase()); + + if (!normalizedLanguage) { + return match; + } + + return prefix + fence + normalizedLanguage + rest; + }); +} + +function targetPagePath(targetRoot, relativePath) { + const withoutExtension = relativePath.replace(/\.mdx?$/i, ""); + const routeFileName = basename(withoutExtension).toLowerCase(); + const isIndexPage = routeFileName === "index" || routeFileName === "page"; + const targetDirectory = isIndexPage ? dirname(withoutExtension) : withoutExtension; + return join(targetRoot, targetDirectory === "." ? "" : targetDirectory, "page.mdx"); +} + +async function fileExists(path) { + try { + await readdir(path); + return true; + } catch { + try { + await readFile(path, "utf8"); + return true; + } catch { + return false; + } + } +} + +async function syncAuthoredRoot(sourceRoot, targetRoot) { + if (!(await fileExists(sourceRoot))) { + return; + } + + const visit = async (currentSourceDirectory, relativeDirectory = "") => { + const entries = await readdir(currentSourceDirectory, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + + const sourcePath = join(currentSourceDirectory, entry.name); + const relativePath = relativeDirectory ? join(relativeDirectory, entry.name) : entry.name; + + if (entry.isDirectory()) { + if (shouldSkipDirectory(entry.name)) { + continue; + } + + await visit(sourcePath, relativePath); + continue; + } + + if (!isMarkdownFile(entry.name)) { + if (shouldSkipFile(entry.name) || !isStaticAssetFile(entry.name)) { + continue; + } + + const targetPath = join(targetRoot, relativePath); + await mkdir(dirname(targetPath), { recursive: true }); + await cp(sourcePath, targetPath, { force: true }); + continue; + } + + const targetPath = targetPagePath(targetRoot, relativePath); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, normalizeMarkdownContent(await readFile(sourcePath, "utf8")), "utf8"); + } + }; + + await visit(sourceRoot); +} + +await rm(resolve(runtimeRoot, "app/docs"), { recursive: true, force: true }); +await mkdir(resolve(runtimeRoot, "app/docs"), { recursive: true }); + +for (const authoredRoot of authoredRoots) { + await syncAuthoredRoot(authoredRoot.source, authoredRoot.target); +} diff --git a/.docs/site/tsconfig.json b/.docs/site/tsconfig.json new file mode 100644 index 0000000..247f602 --- /dev/null +++ b/.docs/site/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": ".", + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..46527ef --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Docs Maintenance Guide +Use this file as the handoff checklist for future edits to this documentation PR. +## Source Layout +- The docs source lives in `docs/`. +- `docs.json` is the Docs Cloud configuration for publishing, previews, and content roots. +- The managed runtime lives in `.docs/site`; edit authored markdown at the repo root instead of generated runtime pages under `.docs/site/app/docs`. +- Keep every page grounded in README content, package metadata, source exports, CLI help, environment examples, or existing docs. +## Docs Routes +- /docs - Introduction +- /docs/installation - Installation +- /docs/quickstart - Quickstart +## Editing Guidelines +- Prefer reader-facing setup, usage, and troubleshooting notes over source inventories. +- Do not add commands, flags, environment variables, routes, imports, or framework names unless they are present in the repository. +- If you add or rename a page, keep its frontmatter title and description accurate and make sure the navigation ordering still includes it. +- Avoid analyzer language such as generated from, source evidence, implementation map, source surface, or detected in files. +## Verification +- Build the docs site with `cd .docs/site && pnpm install && pnpm build` before handing off a docs PR. +- Open `/docs` and at least one generated leaf page to confirm the sidebar and page content match the PR. diff --git a/docs.json b/docs.json new file mode 100644 index 0000000..efe3175 --- /dev/null +++ b/docs.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://docs.farming-labs.dev/schema/docs.json", + "version": 1, + "docs": { + "mode": "frameworkless", + "runtime": "nextjs", + "root": ".docs/site" + }, + "content": { + "docsRoot": "docs", + "apiReferenceRoot": "api-reference", + "openapi": [] + }, + "cloud": { + "apiKey": { + "env": "DOCS_CLOUD_API_KEY" + }, + "preview": { + "enabled": true + }, + "publish": { + "mode": "draft-pr", + "baseBranch": "main" + } + } +} diff --git a/docs/index.mdx b/docs/index.mdx new file mode 100644 index 0000000..6ef4215 --- /dev/null +++ b/docs/index.mdx @@ -0,0 +1,15 @@ +--- +title: "Introduction" +description: "A lightweight JavaScript library for managing hooks — register, invoke, update, and sequence them with full control over execution order." +order: 0 +--- + +# Introduction + +Befter is a lightweight JavaScript library for managing hooks. It gives you a clean API to register hooks by name, invoke them in sequence, update or remove them at runtime, and precisely control whether a hook runs before or after another — all without pulling in a heavy event system. + +The core primitive is `createBefter`, which returns a hook registry you own. From there you attach callbacks with `hook`, trigger them with `callHook`, and clean up with `removeHook` or `removeHookItself`. Every function is fully typed, so TypeScript infers the callback signatures from the hook keys you define. + +Befter ships with both in-process (local) and Redis-backed storage adapters, so the same hook model scales from a single Node.js process to a distributed system. Serial and parallel execution helpers give you fine-grained control over how a group of hooks is called. + +Ready to add Befter to your project? Head to [Installation](/docs/installation) to add the package, or jump straight to [Quickstart](/docs/quickstart) to see a working example in under a minute. diff --git a/docs/installation.mdx b/docs/installation.mdx new file mode 100644 index 0000000..008d10c --- /dev/null +++ b/docs/installation.mdx @@ -0,0 +1,57 @@ +--- +title: "Installation" +description: "Install and configure Befter." +order: 10 +--- + +# Installation + +Befter is published to npm as `@farming-labs/befter`. You need Node.js 18 or later before you begin. + +## Install the package + +Choose your package manager: + +```bash +npm install @farming-labs/befter +``` + +```bash +pnpm add @farming-labs/befter +``` + +```bash +yarn add @farming-labs/befter +``` + +## Import the library + +Once installed, import `createBefter` to create your first hook registry: + +```ts +import { createBefter } from "@farming-labs/befter" +``` + +That single import is all you need to get started. The package ships as an ES module with TypeScript declarations included — no extra `@types` package required. + +## Verify the install + +Create a small smoke-test file and run it with Node.js or your preferred runtime: + +```ts +import { createBefter } from "@farming-labs/befter" + +const befter = createBefter({}) +console.log("Befter ready:", typeof befter.hook === "function") +``` + +```bash +node smoke-test.mjs +# Befter ready: true +``` + +If you see `Befter ready: true`, the package is wired up correctly. + +## Next steps + +With the package installed, head to [Quickstart](/docs/quickstart) to register your first hook and call it. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx new file mode 100644 index 0000000..bc0c826 --- /dev/null +++ b/docs/quickstart.mdx @@ -0,0 +1,75 @@ +--- +title: "Quickstart" +description: "Run Befter for the first time." +order: 20 +--- + +# Quickstart + +This page walks you through creating a Befter instance, registering a hook, and invoking it — the complete loop in a single file. You should have Befter installed already; if not, see [Installation](/docs/installation) first. + +## Create a registry + +`createBefter` returns a registry that holds all your named hooks. Pass an empty object to start with a blank slate: + +```ts +import { createBefter } from "@farming-labs/befter" + +const befter = createBefter({}) +``` + +## Register a hook + +Use `hook` to attach a callback to a named key. The name is a plain string that becomes the identifier you use to invoke or remove the hook later: + +```ts +befter.hook("user:created", async (user) => { + console.log("New user registered:", user.name) +}) +``` + +You can register multiple callbacks under the same key — Befter tracks them all and calls them in registration order by default. + +## Call the hook + +Use `callHook` to invoke every callback registered under a key, passing whatever arguments your callbacks expect: + +```ts +await befter.callHook("user:created", { name: "Alice" }) +// → New user registered: Alice +``` + +`callHook` returns a promise that resolves once all callbacks have finished, making it safe to `await` in async workflows. + +## Update or remove a hook + +You can swap out a callback at runtime with `updateHook`, or tear it down entirely with `removeHook`: + +```ts +// Remove all callbacks for a key +befter.removeHook("user:created") +``` + +Use `removeHookItself` when you have a direct reference to the callback and want to remove only that one entry, leaving any other callbacks on the same key intact. + +## Complete example + +```ts +import { createBefter } from "@farming-labs/befter" + +const befter = createBefter({}) + +befter.hook("order:placed", async (order) => { + console.log(`Order #${order.id} received`) +}) + +befter.hook("order:placed", async (order) => { + console.log(`Sending confirmation email to ${order.email}`) +}) + +await befter.callHook("order:placed", { id: 42, email: "alice@example.com" }) +// → Order #42 received +// → Sending confirmation email to alice@example.com +``` + +Both callbacks run in sequence under the same key — no extra wiring needed. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ff5faa..ef0d119 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - "apps/*" - "packages/*" + - ".docs/site"