@njinlabs/njin 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # njin
2
2
 
3
+ [![CI](https://github.com/njinlabs/njin/actions/workflows/ci.yml/badge.svg)](https://github.com/njinlabs/njin/actions/workflows/ci.yml)
4
+ [![CodeQL](https://github.com/njinlabs/njin/actions/workflows/codeql.yml/badge.svg)](https://github.com/njinlabs/njin/actions/workflows/codeql.yml)
5
+ [![npm version](https://img.shields.io/npm/v/%40njinlabs%2Fnjin.svg)](https://www.npmjs.com/package/@njinlabs/njin)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
3
8
  A modern framework for building company profiles, landing pages, and content-driven websites. Define your data model once — get a full REST API, admin panel schema, and server-rendered website out of the box.
4
9
 
5
10
  ## Stack
@@ -13,7 +18,7 @@ A modern framework for building company profiles, landing pages, and content-dri
13
18
  ## Quick start
14
19
 
15
20
  ```bash
16
- bun create njin-app my-web
21
+ bunx @njinlabs/njin create my-web
17
22
  cd my-web
18
23
  bunx njin dev
19
24
  ```
@@ -131,6 +136,37 @@ This automatically generates:
131
136
  <title>{{ settings.siteName }}</title>
132
137
  ```
133
138
 
139
+ ## Helpers — custom template functions
140
+
141
+ `helpers` register a plain, stateless function as an Edge global — unlike `vars`/`models`, there's no DB record and no auto-generated REST endpoint, just a function callable from any `.edge` template.
142
+
143
+ ```ts
144
+ // src/helpers/format_date.ts
145
+ import { defineHelper } from "@njinlabs/njin";
146
+ import moment from "moment";
147
+
148
+ export default defineHelper("formatDate", (date: string, format = "DD MMM YYYY") =>
149
+ moment(date).format(format),
150
+ );
151
+ ```
152
+
153
+ Register it in `config.ts`:
154
+
155
+ ```ts
156
+ // config.ts
157
+ export default defineConfig({
158
+ helpers: [
159
+ () => import("./src/helpers/format_date"),
160
+ ],
161
+ });
162
+ ```
163
+
164
+ Use it directly in a template, no `await` needed since it's a plain synchronous function (an `async` `fn` works too, called with `await` like any other async global):
165
+
166
+ ```edge
167
+ <p>{{ formatDate(post.createdAt) }}</p>
168
+ ```
169
+
134
170
  ## Events
135
171
 
136
172
  A type-safe event bus for fan-out notifications (e.g. "an order was paid", "a user registered") — different from model hooks (`beforeCreate`/`afterCreate`/...): hooks are scoped to one model and can abort the operation by throwing, while events are fire-and-forget — a listener that throws is logged but never stops other listeners or the code that dispatched.
@@ -358,6 +394,7 @@ adapters: {
358
394
  ## Commands
359
395
 
360
396
  ```bash
397
+ bunx @njinlabs/njin create my-web # Scaffold a new project
361
398
  bunx njin dev # Start dev server (Elysia + Vite HMR)
362
399
  bunx njin build # Build for production -> ./out (public/, _admin/, views/, compiled server)
363
400
  bunx njin start # Run from source in production mode (no compile)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@njinlabs/njin",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A modern framework for building company profiles, landing pages, and content-driven websites.",
5
5
  "type": "module",
6
6
  "keywords": ["bun", "elysia", "surrealdb", "edgejs", "cms", "framework"],
@@ -0,0 +1,82 @@
1
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ const c = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", cyan: "\x1b[36m", green: "\x1b[32m" };
5
+
6
+ const targetArg = process.argv[3] ?? ".";
7
+ const targetDir = resolve(process.cwd(), targetArg);
8
+ const projectName = targetArg === "." ? "njin-app" : targetArg.split(/[\\/]/).filter(Boolean).pop()!;
9
+
10
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
11
+ console.error(`✖ Directory "${targetArg}" already exists and is not empty.`);
12
+ process.exit(1);
13
+ }
14
+
15
+ // This CLI's own package.json — read at runtime (not a static JSON import) so it
16
+ // resolves correctly whether njin is a project-local dependency or fetched fresh
17
+ // by `bunx`. Doubles as the source of truth for which tagged template to fetch,
18
+ // and for the version pin written into the scaffolded project below.
19
+ const pkg = await Bun.file(join(import.meta.dir, "../../package.json")).json();
20
+ const tag = `v${pkg.version}`;
21
+ const tarballUrl = `https://codeload.github.com/njinlabs/njin/tar.gz/refs/tags/${tag}`;
22
+
23
+ console.log(`\nFetching template for njin ${tag}...\n`);
24
+
25
+ const res = await fetch(tarballUrl);
26
+ if (!res.ok || !res.body) {
27
+ console.error(
28
+ `✖ Could not download the project template for njin ${tag} (HTTP ${res.status}).\n` +
29
+ ` Check your internet connection, or that tag "${tag}" exists at github.com/njinlabs/njin.`,
30
+ );
31
+ process.exit(1);
32
+ }
33
+
34
+ mkdirSync(targetDir, { recursive: true });
35
+
36
+ // `--strip-components=2` drops the `njin-<version>/template/` prefix baked into
37
+ // GitHub's tag-tarball layout, so template/ contents land directly in targetDir.
38
+ const tar = Bun.spawn(["tar", "-xz", "--strip-components=2", "-C", targetDir, `njin-${pkg.version}/template`], {
39
+ stdin: res.body,
40
+ stdout: "inherit",
41
+ stderr: "inherit",
42
+ });
43
+
44
+ const tarExitCode = await tar.exited;
45
+
46
+ if (tarExitCode !== 0) {
47
+ console.error(
48
+ `✖ Failed to extract the template (tar exited with code ${tarExitCode}).\n` +
49
+ ` Make sure "tar" is installed and available on your PATH.`,
50
+ );
51
+ process.exit(tarExitCode);
52
+ }
53
+
54
+ const pkgPath = join(targetDir, "package.json");
55
+ const scaffoldedPkg = await Bun.file(pkgPath).json();
56
+ scaffoldedPkg.name = projectName;
57
+ scaffoldedPkg.dependencies = { "@njinlabs/njin": `^${pkg.version}`, ...scaffoldedPkg.dependencies };
58
+ await Bun.write(pkgPath, JSON.stringify(scaffoldedPkg, null, 2) + "\n");
59
+
60
+ console.log("Installing dependencies...\n");
61
+
62
+ const install = Bun.spawn(["bun", "install"], {
63
+ cwd: targetDir,
64
+ stdout: "inherit",
65
+ stderr: "inherit",
66
+ });
67
+
68
+ const installExitCode = await install.exited;
69
+
70
+ if (installExitCode !== 0) {
71
+ console.error(`\n✖ "bun install" failed (exit code ${installExitCode}).`);
72
+ console.error(` The project was scaffolded at ${targetDir}, but dependencies are not installed.`);
73
+ console.error(` Run "cd ${targetArg} && bun install" manually to retry.\n`);
74
+ process.exit(installExitCode);
75
+ }
76
+
77
+ console.log(`
78
+ ${c.bold}${c.cyan}njin create${c.reset} ${c.dim}done${c.reset}
79
+
80
+ ${c.green}➜${c.reset} cd ${targetArg === "." ? "." : targetArg}
81
+ ${c.green}➜${c.reset} bunx njin dev
82
+ `);
package/src/cli/index.ts CHANGED
@@ -2,6 +2,9 @@
2
2
  const [command] = process.argv.slice(2);
3
3
 
4
4
  switch (command) {
5
+ case "create":
6
+ await import("./create");
7
+ break;
5
8
  case "dev":
6
9
  await import("./dev");
7
10
  break;
@@ -12,10 +15,11 @@ switch (command) {
12
15
  await import("./build");
13
16
  break;
14
17
  default:
15
- console.log(`Usage: njin <dev|build|start>
18
+ console.log(`Usage: njin <create|dev|build|start>
16
19
 
17
- dev Run the dev server (Vite HMR + live reload)
18
- build Build for production -> ./out (public/, _admin/, views/, server)
19
- start Run from source in production mode (no compile)`);
20
+ create <dir> Scaffold a new project (defaults to current directory)
21
+ dev Run the dev server (Vite HMR + live reload)
22
+ build Build for production -> ./out (public/, _admin/, views/, server)
23
+ start Run from source in production mode (no compile)`);
20
24
  process.exit(command ? 1 : 0);
21
25
  }
@@ -2,6 +2,7 @@ import type { AnyElysia } from "elysia";
2
2
  import type { FileAdapter } from "../modules/file";
3
3
  import { join } from "node:path";
4
4
  import bunFilesystemAdapter from "./adapters/bun_filesystem";
5
+ import type { Helper } from "./helper";
5
6
  import type { makeModel } from "./model";
6
7
  import type { Plugin } from "./plugin";
7
8
  import type { makeVars } from "./vars";
@@ -28,6 +29,11 @@ export type RouteFactory = () => Promise<{ default: AnyElysia }>;
28
29
  // a singleton settings object, not a list of records like a model.
29
30
  export type VarsFactory = () => Promise<{ default: ReturnType<typeof makeVars> }>;
30
31
 
32
+ // A helper file's default export is one defineHelper() — a stateless function
33
+ // registered as an Edge global by its own name, unlike models/vars which are
34
+ // DB-backed objects registered by prefix.
35
+ export type HelperFactory = () => Promise<{ default: Helper }>;
36
+
31
37
  export type NjinConfig = {
32
38
  port?: number;
33
39
  db?: {
@@ -50,6 +56,7 @@ export type NjinConfig = {
50
56
  events?: EventFactory[];
51
57
  routes?: RouteFactory[];
52
58
  vars?: VarsFactory[];
59
+ helpers?: HelperFactory[];
53
60
  plugins?: Plugin[];
54
61
  };
55
62
 
@@ -64,6 +71,7 @@ export type ResolvedConfig = {
64
71
  events: EventFactory[];
65
72
  routes: RouteFactory[];
66
73
  vars: VarsFactory[];
74
+ helpers: HelperFactory[];
67
75
  // Everything else a Plugin contributes (models/vars/hooks/events/routes) is already
68
76
  // flattened into the arrays above — init() is the only part that can't be merged away,
69
77
  // so it's the only piece of each Plugin that survives resolution on its own.
@@ -131,6 +139,7 @@ export const loadConfig = async (preloaded?: NjinConfig): Promise<void> => {
131
139
  events: [...plugins.flatMap((p) => p.events ?? []), ...(userConfig.events ?? [])],
132
140
  routes: [...plugins.flatMap((p) => p.routes ?? []), ...(userConfig.routes ?? [])],
133
141
  vars: [...plugins.flatMap((p) => p.vars ?? []), ...(userConfig.vars ?? [])],
142
+ helpers: [...plugins.flatMap((p) => p.helpers ?? []), ...(userConfig.helpers ?? [])],
134
143
  pluginInits: plugins.map((p) => p.init).filter((fn): fn is () => Promise<void> | void => typeof fn === "function"),
135
144
  };
136
145
  };
@@ -0,0 +1,5 @@
1
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2
+ export type Helper = { name: string; fn: (...args: any[]) => unknown };
3
+
4
+ // Identity function — DX/typing only, same purpose as defineConfig/definePlugin.
5
+ export const defineHelper = (name: string, fn: Helper["fn"]): Helper => ({ name, fn });
@@ -272,6 +272,7 @@ export const makeModel = <Rules extends z.ZodObject>(
272
272
  export * from "./data_type";
273
273
  export * from "./hooks";
274
274
  export * from "../event";
275
+ export * from "../helper";
275
276
  export * from "../plugin";
276
277
  export * from "../route";
277
278
  export * from "../vars";
@@ -1,8 +1,9 @@
1
- import type { EventFactory, HookFactory, ModelFactory, RouteFactory, VarsFactory } from "./config";
1
+ import type { EventFactory, HelperFactory, HookFactory, ModelFactory, RouteFactory, VarsFactory } from "./config";
2
2
 
3
3
  export type Plugin = {
4
4
  models?: ModelFactory[];
5
5
  vars?: VarsFactory[];
6
+ helpers?: HelperFactory[];
6
7
  hooks?: HookFactory[];
7
8
  events?: EventFactory[];
8
9
  routes?: RouteFactory[];
@@ -102,6 +102,11 @@ const view = makeModule(() => {
102
102
  edge.global(group.prefix, group);
103
103
  }
104
104
 
105
+ for (const helperPromise of getConfig().helpers) {
106
+ const { default: helper } = await helperPromise();
107
+ edge.global(helper.name, helper.fn);
108
+ }
109
+
105
110
  const controller = new Elysia();
106
111
 
107
112
  if (!isDev) {