@apex-stack/core 0.1.18 → 0.1.20

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.
@@ -5,7 +5,7 @@ import {
5
5
  renderIslandsPage,
6
6
  renderPage,
7
7
  scanPages
8
- } from "./chunk-VALQEQD3.js";
8
+ } from "./chunk-4FUWZLVW.js";
9
9
  import "./chunk-MZVLRU3R.js";
10
10
 
11
11
  // src/commands/build.ts
@@ -38,8 +38,8 @@ async function buildClient(root, routes, outDir) {
38
38
  return [
39
39
  `import Alpine from 'alpinejs'`,
40
40
  `import ${JSON.stringify(pageId)}`,
41
- `window.Alpine = Alpine`,
42
- `Alpine.start()`
41
+ "window.Alpine = Alpine",
42
+ "Alpine.start()"
43
43
  ].join("\n");
44
44
  }
45
45
  }
@@ -55,7 +55,9 @@ async function buildClient(root, routes, outDir) {
55
55
  rollupOptions: { input }
56
56
  }
57
57
  });
58
- const manifest = JSON.parse(readFileSync(join(outDir, ".vite", "manifest.json"), "utf8"));
58
+ const manifest = JSON.parse(
59
+ readFileSync(join(outDir, ".vite", "manifest.json"), "utf8")
60
+ );
59
61
  const hrefs = /* @__PURE__ */ new Map();
60
62
  for (const r of routes) {
61
63
  const virt = `${VIRT}${r.pageId}`;
@@ -136,8 +138,16 @@ var buildCommand = defineCommand({
136
138
  args: {
137
139
  root: { type: "positional", required: false, description: "Project root", default: "." },
138
140
  outDir: { type: "string", description: "Output directory", default: "dist" },
139
- islands: { type: "boolean", description: "Static-first islands mode (zero-JS static)", default: false },
140
- server: { type: "boolean", description: "Build a Node server (dynamic routes + API/MCP)", default: false }
141
+ islands: {
142
+ type: "boolean",
143
+ description: "Static-first islands mode (zero-JS static)",
144
+ default: false
145
+ },
146
+ server: {
147
+ type: "boolean",
148
+ description: "Build a Node server (dynamic routes + API/MCP)",
149
+ default: false
150
+ }
141
151
  },
142
152
  async run({ args }) {
143
153
  const root = resolve(process.cwd(), args.root);
@@ -179,7 +189,7 @@ var buildCommand = defineCommand({
179
189
  if (existsSync2(pub)) cpSync(pub, outDir, { recursive: true });
180
190
  console.log(
181
191
  `
182
- Built ${staticRoutes.length} page(s) \u2192 ${args.outDir}/` + (args.islands ? " (islands / static-first)" : " (prerendered + hydrated)")
192
+ Built ${staticRoutes.length} page(s) \u2192 ${args.outDir}/${args.islands ? " (islands / static-first)" : " (prerendered + hydrated)"}`
183
193
  );
184
194
  if (dynamic.length) {
185
195
  console.log(
@@ -89,14 +89,17 @@ function scanPages(root) {
89
89
  const parts = rel.replace(/\.alpine$/, "").split("/");
90
90
  if (parts[parts.length - 1] === "index") parts.pop();
91
91
  const segments = parts.map((p) => {
92
+ const catchAll = /^\[\.\.\.(.+)\]$/.exec(p);
93
+ if (catchAll) return { catchAll: catchAll[1] };
92
94
  const m = /^\[(.+)\]$/.exec(p);
93
95
  return m ? { param: m[1] } : { literal: p };
94
96
  });
95
- const isDynamic = segments.some((s) => s.param !== void 0);
96
- const pattern = `/${segments.map((s) => s.param ? `:${s.param}` : s.literal).join("/")}`;
97
+ const isDynamic = segments.some((s) => s.param !== void 0 || s.catchAll !== void 0);
98
+ const pattern = `/${segments.map((s) => s.catchAll ? `:${s.catchAll}*` : s.param ? `:${s.param}` : s.literal).join("/")}`;
97
99
  return { pageId, pattern, segments, isDynamic };
98
100
  });
99
- return routes.sort((a, b) => Number(a.isDynamic) - Number(b.isDynamic));
101
+ const rank = (r) => r.segments.some((s) => s.catchAll) ? 2 : r.isDynamic ? 1 : 0;
102
+ return routes.sort((a, b) => rank(a) - rank(b));
100
103
  }
101
104
  function pathSegments(url) {
102
105
  const path = url.split("?")[0] ?? "/";
@@ -105,6 +108,26 @@ function pathSegments(url) {
105
108
  function matchRoute(routes, url) {
106
109
  const segs = pathSegments(url);
107
110
  for (const route of routes) {
111
+ const last = route.segments[route.segments.length - 1];
112
+ const isCatchAll = Boolean(last?.catchAll);
113
+ if (isCatchAll) {
114
+ const lead = route.segments.slice(0, -1);
115
+ if (segs.length < lead.length + 1) continue;
116
+ const params2 = {};
117
+ let ok2 = true;
118
+ for (let i = 0; i < lead.length; i++) {
119
+ const rs = lead[i];
120
+ const value = segs[i];
121
+ if (rs.param) params2[rs.param] = decodeURIComponent(value);
122
+ else if (rs.literal !== value) {
123
+ ok2 = false;
124
+ break;
125
+ }
126
+ }
127
+ if (!ok2) continue;
128
+ params2[last?.catchAll] = segs.slice(lead.length).map(decodeURIComponent).join("/");
129
+ return { pageId: route.pageId, params: params2 };
130
+ }
108
131
  if (route.segments.length !== segs.length) continue;
109
132
  const params = {};
110
133
  let ok = true;
@@ -153,7 +176,11 @@ function storesInitialState(stores) {
153
176
  }
154
177
 
155
178
  // src/dev/renderPage.ts
156
- import { renderComponent, renderFragment, stateIsland } from "@apex-stack/kit";
179
+ import {
180
+ renderComponent,
181
+ renderFragment,
182
+ stateIsland
183
+ } from "@apex-stack/kit";
157
184
  function escAttr(s) {
158
185
  return String(s).replace(
159
186
  /[&<>"]/g,
@@ -163,10 +190,14 @@ function escAttr(s) {
163
190
  function renderHead(head) {
164
191
  const parts = [`<title>${head?.title ? escAttr(head.title) : "Apex JS"}</title>`];
165
192
  for (const m of head?.meta ?? []) {
166
- parts.push(`<meta ${Object.entries(m).map(([k, v]) => `${k}="${escAttr(v)}"`).join(" ")} />`);
193
+ parts.push(
194
+ `<meta ${Object.entries(m).map(([k, v]) => `${k}="${escAttr(v)}"`).join(" ")} />`
195
+ );
167
196
  }
168
197
  for (const l of head?.link ?? []) {
169
- parts.push(`<link ${Object.entries(l).map(([k, v]) => `${k}="${escAttr(v)}"`).join(" ")} />`);
198
+ parts.push(
199
+ `<link ${Object.entries(l).map(([k, v]) => `${k}="${escAttr(v)}"`).join(" ")} />`
200
+ );
170
201
  }
171
202
  return parts.join("\n ");
172
203
  }
@@ -111,7 +111,10 @@ function buildServer(entries) {
111
111
  inputSchema: entry.route.inputShape ?? {}
112
112
  },
113
113
  async (args) => {
114
- const result = await entry.route.handler({ input: args ?? {}, url: `mcp://${entry.mcpName}` });
114
+ const result = await entry.route.handler({
115
+ input: args ?? {},
116
+ url: `mcp://${entry.mcpName}`
117
+ });
115
118
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
116
119
  }
117
120
  );
package/dist/cli.js CHANGED
@@ -11,11 +11,21 @@ import { defineCommand as defineCommand2, runMain } from "citty";
11
11
 
12
12
  // src/commands/new.ts
13
13
  import { spawn, spawnSync } from "child_process";
14
- import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "fs";
14
+ import { cpSync, existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from "fs";
15
15
  import { basename, join, resolve } from "path";
16
16
  import { fileURLToPath } from "url";
17
17
  import { defineCommand } from "citty";
18
18
  var TEMPLATE_DIR = fileURLToPath(new URL("../templates/default", import.meta.url));
19
+ function substituteName(dir, name) {
20
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
21
+ const p = join(dir, entry.name);
22
+ if (entry.isDirectory()) substituteName(p, name);
23
+ else {
24
+ const txt = readFileSync(p, "utf8");
25
+ if (txt.includes("{{name}}")) writeFileSync(p, txt.replaceAll("{{name}}", name));
26
+ }
27
+ }
28
+ }
19
29
  function detectPackageManager() {
20
30
  const ua = process.env.npm_config_user_agent || "";
21
31
  if (ua.startsWith("pnpm")) return "pnpm";
@@ -37,9 +47,22 @@ function installAsync(pm, cwd) {
37
47
  var newCommand = defineCommand({
38
48
  meta: { name: "new", description: "Scaffold a new Apex JS app" },
39
49
  args: {
40
- dir: { type: "positional", required: false, description: "Target directory", default: "apex-app" },
41
- install: { type: "boolean", default: true, description: "Install dependencies (use --no-install to skip)" },
42
- git: { type: "boolean", default: true, description: "Initialize a git repository (use --no-git to skip)" }
50
+ dir: {
51
+ type: "positional",
52
+ required: false,
53
+ description: "Target directory",
54
+ default: "apex-app"
55
+ },
56
+ install: {
57
+ type: "boolean",
58
+ default: true,
59
+ description: "Install dependencies (use --no-install to skip)"
60
+ },
61
+ git: {
62
+ type: "boolean",
63
+ default: true,
64
+ description: "Initialize a git repository (use --no-git to skip)"
65
+ }
43
66
  },
44
67
  async run({ args }) {
45
68
  const dir = String(args.dir);
@@ -55,10 +78,7 @@ var newCommand = defineCommand({
55
78
  cpSync(TEMPLATE_DIR, target, { recursive: true });
56
79
  const gitignore = join(target, "_gitignore");
57
80
  if (existsSync(gitignore)) renameSync(gitignore, join(target, ".gitignore"));
58
- for (const rel of ["package.json", "README.md"]) {
59
- const file = join(target, rel);
60
- if (existsSync(file)) writeFileSync(file, readFileSync(file, "utf8").replaceAll("{{name}}", name));
61
- }
81
+ substituteName(target, name);
62
82
  log(` ${color.green("\u2713")} Created ${color.bold(dir)}`);
63
83
  const pm = detectPackageManager();
64
84
  let gitOk = false;
@@ -73,7 +93,9 @@ var newCommand = defineCommand({
73
93
  if (gitOk) log(` ${color.green("\u2713")} Initialized a git repository`);
74
94
  let installed = false;
75
95
  if (args.install) {
76
- const sp = spinner(`Installing dependencies with ${pm}\u2026 ${color.dim("(first run can take a minute)")}`);
96
+ const sp = spinner(
97
+ `Installing dependencies with ${pm}\u2026 ${color.dim("(first run can take a minute)")}`
98
+ );
77
99
  installed = await installAsync(pm, target);
78
100
  if (installed) sp.succeed(`Dependencies installed with ${pm}`);
79
101
  else sp.fail(`Install failed \u2014 run ${color.cyan(`${pm} install`)} inside ${dir}`);
@@ -84,10 +106,14 @@ var newCommand = defineCommand({
84
106
  log(` ${color.cyan(`cd ${dir}`)}`);
85
107
  if (!installed) log(` ${color.cyan(pm === "yarn" ? "yarn" : `${pm} install`)}`);
86
108
  log(` ${color.cyan("apex dev")} ${color.gray("# \u2192 http://localhost:3000")}`);
87
- log(`
88
- ${color.gray("Not installed globally? Use")} ${color.cyan(`${runPrefix} dev`)}${color.gray(".")}`);
89
- log(` ${color.gray("Islands mode:")} ${color.cyan("apex dev --islands")}${color.gray(" \xB7 API routes are also MCP tools at /mcp.")}
90
- `);
109
+ log(
110
+ `
111
+ ${color.gray("Not installed globally? Use")} ${color.cyan(`${runPrefix} dev`)}${color.gray(".")}`
112
+ );
113
+ log(
114
+ ` ${color.gray("Islands mode:")} ${color.cyan("apex dev --islands")}${color.gray(" \xB7 API routes are also MCP tools at /mcp.")}
115
+ `
116
+ );
91
117
  }
92
118
  });
93
119
 
@@ -109,27 +135,31 @@ var main = defineCommand2({
109
135
  },
110
136
  subCommands: {
111
137
  new: newCommand,
112
- dev: () => import("./dev-W3D2FCEP.js").then((m) => m.devCommand),
113
- build: () => import("./build-DXLV2X5C.js").then((m) => m.buildCommand),
114
- start: () => import("./start-254YG2BY.js").then((m) => m.startCommand),
115
- make: () => import("./make-JAW22LQZ.js").then((m) => m.makeCommand),
116
- migrate: () => import("./migrate-NOGFOFV2.js").then((m) => m.migrateCommand),
117
- mcp: () => import("./mcp-DL4J6JFJ.js").then((m) => m.mcpCommand)
138
+ dev: () => import("./dev-6YCKNYJ4.js").then((m) => m.devCommand),
139
+ build: () => import("./build-PETU3URU.js").then((m) => m.buildCommand),
140
+ start: () => import("./start-V2TBGKWH.js").then((m) => m.startCommand),
141
+ make: () => import("./make-WM6DLDCR.js").then((m) => m.makeCommand),
142
+ migrate: () => import("./migrate-X6LIHMIE.js").then((m) => m.migrateCommand),
143
+ mcp: () => import("./mcp-CH7L4GF3.js").then((m) => m.mcpCommand)
118
144
  },
119
145
  // Shown for a bare `apex` (no subcommand): the brand banner + a command menu.
120
146
  run({ rawArgs }) {
121
147
  if (rawArgs.length > 0) return;
122
148
  process.stdout.write(banner());
123
149
  const log = console.log;
124
- log(` ${color.bold("Usage")} ${color.gray("apex")} ${color.cyan("<command>")} ${color.gray("[options]")}
125
- `);
150
+ log(
151
+ ` ${color.bold("Usage")} ${color.gray("apex")} ${color.cyan("<command>")} ${color.gray("[options]")}
152
+ `
153
+ );
126
154
  log(` ${color.bold("Commands")}`);
127
155
  for (const [name, desc] of COMMANDS) {
128
156
  log(` ${color.cyan(`apex ${name}`.padEnd(13))} ${color.gray(desc)}`);
129
157
  }
130
- log(`
158
+ log(
159
+ `
131
160
  ${color.gray("Run")} ${color.cyan("apex <command> --help")} ${color.gray("for details.")}
132
- `);
161
+ `
162
+ );
133
163
  }
134
164
  });
135
165
  runMain(main);
@@ -12,7 +12,11 @@ var devCommand = defineCommand({
12
12
  args: {
13
13
  root: { type: "positional", required: false, description: "Project root", default: "." },
14
14
  port: { type: "string", description: "Port to listen on", default: "3000" },
15
- islands: { type: "boolean", description: "Render in islands mode (static-first)", default: false }
15
+ islands: {
16
+ type: "boolean",
17
+ description: "Render in islands mode (static-first)",
18
+ default: false
19
+ }
16
20
  },
17
21
  async run({ args }) {
18
22
  const root = resolve(process.cwd(), String(args.root));
@@ -20,7 +24,7 @@ var devCommand = defineCommand({
20
24
  process.stdout.write(banner());
21
25
  const sp = spinner(`Starting dev server${args.islands ? " (islands mode)" : ""}\u2026`);
22
26
  try {
23
- const { startDevServer } = await import("./server-VYF5SRVI.js");
27
+ const { startDevServer } = await import("./server-62UM2N5C.js");
24
28
  const { port: actual } = await startDevServer({ root, port, islands: Boolean(args.islands) });
25
29
  sp.succeed("Dev server ready");
26
30
  ready([
@@ -79,32 +79,74 @@ export default defineApexRoute({
79
79
  })
80
80
  `;
81
81
  }
82
+ function serviceTemplate(name) {
83
+ const cls = `${pascalCase(name)}Service`;
84
+ return `/**
85
+ * ${cls} \u2014 business logic as a plain, testable class. Keep routes and loaders
86
+ * thin and delegate to services like this one (the clean-code backbone).
87
+ */
88
+ export class ${cls} {
89
+ // Replace with your methods.
90
+ run(input: string): string {
91
+ return input
92
+ }
93
+ }
94
+ `;
95
+ }
96
+ function testTemplate(name) {
97
+ return `import { describe, expect, it } from 'vitest'
98
+
99
+ describe('${name}', () => {
100
+ it('works', () => {
101
+ expect(true).toBe(true)
102
+ })
103
+ })
104
+ `;
105
+ }
82
106
  function plan(kind, name, root) {
83
107
  switch (kind) {
84
108
  case "page":
85
109
  return { path: join(root, "pages", `${name}.alpine`), contents: pageTemplate(name) };
86
110
  case "component":
87
- return { path: join(root, "components", `${pascalCase(name)}.alpine`), contents: componentTemplate() };
111
+ return {
112
+ path: join(root, "components", `${pascalCase(name)}.alpine`),
113
+ contents: componentTemplate()
114
+ };
88
115
  case "api":
89
116
  return { path: join(root, "server", "api", `${name}.ts`), contents: apiTemplate(name) };
90
117
  case "store":
91
118
  return { path: join(root, "stores", `${name}.ts`), contents: storeTemplate(name) };
92
119
  case "layout":
93
120
  return { path: join(root, "layouts", `${name}.alpine`), contents: layoutTemplate() };
121
+ case "service":
122
+ return {
123
+ path: join(root, "services", `${pascalCase(name)}Service.ts`),
124
+ contents: serviceTemplate(name)
125
+ };
126
+ case "test":
127
+ return { path: join(root, "tests", `${name}.test.ts`), contents: testTemplate(name) };
94
128
  }
95
129
  }
96
130
  var makeCommand = defineCommand({
97
- meta: { name: "make", description: "Generate a page, component, API route, store, or layout" },
131
+ meta: {
132
+ name: "make",
133
+ description: "Generate a page, component, API route, store, layout, service, or test"
134
+ },
98
135
  args: {
99
- kind: { type: "positional", required: true, description: "page | component | api | store | layout" },
136
+ kind: {
137
+ type: "positional",
138
+ required: true,
139
+ description: "page | component | api | store | layout | service | test"
140
+ },
100
141
  name: { type: "positional", required: true, description: "Name (about, Counter, todos, \u2026)" },
101
142
  root: { type: "string", description: "Project root", default: "." }
102
143
  },
103
144
  run({ args }) {
104
145
  const kind = args.kind;
105
- if (kind !== "page" && kind !== "component" && kind !== "api" && kind !== "store" && kind !== "layout") {
146
+ const kinds = ["page", "component", "api", "store", "layout", "service", "test"];
147
+ if (!kinds.includes(kind)) {
106
148
  console.error(`
107
- Unknown type "${args.kind}". Use: page | component | api | store | layout
149
+ Unknown type "${args.kind}". Use: ${kinds.join(" | ")}
108
150
  `);
109
151
  process.exit(1);
110
152
  }
@@ -30,7 +30,7 @@ var mcpCommand = defineCommand({
30
30
  console.log(`
31
31
  \x1B[36m${args.call}\x1B[0m(${args.args}) \u2192`);
32
32
  for (const part of result.content) {
33
- console.log(" " + (part.text ?? JSON.stringify(part)));
33
+ console.log(` ${part.text ?? JSON.stringify(part)}`);
34
34
  }
35
35
  console.log();
36
36
  } else {
@@ -19,7 +19,9 @@ var migrateCommand = defineCommand({
19
19
  const require2 = createRequire(join(root, "package.json"));
20
20
  data = await import(pathToFileURL(require2.resolve("@apex-stack/data")).href);
21
21
  } catch {
22
- console.error("\n @apex-stack/data is not installed in this project. Run: npm i @apex-stack/data\n");
22
+ console.error(
23
+ "\n @apex-stack/data is not installed in this project. Run: npm i @apex-stack/data\n"
24
+ );
23
25
  process.exit(1);
24
26
  }
25
27
  const config = args.driver === "postgres" ? { driver: "postgres", url: args.url } : args.driver === "pglite" ? { driver: "pglite", dir: args.url } : resolve(root, args.db);
@@ -5,7 +5,7 @@ import {
5
5
  createApiHandler,
6
6
  createMcpHandler,
7
7
  loadApiRoutes
8
- } from "./chunk-JWYNLP4L.js";
8
+ } from "./chunk-G77MLFUJ.js";
9
9
  import "./chunk-HRJTOSYH.js";
10
10
  import {
11
11
  loadStores,
@@ -13,7 +13,7 @@ import {
13
13
  renderIslandsPage,
14
14
  renderPage,
15
15
  scanPages
16
- } from "./chunk-VALQEQD3.js";
16
+ } from "./chunk-4FUWZLVW.js";
17
17
  import "./chunk-MZVLRU3R.js";
18
18
 
19
19
  // src/dev/server.ts
@@ -36,20 +36,24 @@ import { createServer as createViteServer } from "vite";
36
36
  // src/dev/errorPage.ts
37
37
  import { existsSync, readFileSync } from "fs";
38
38
  function esc(s) {
39
- return s.replace(/[&<>"]/g, (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;");
39
+ return s.replace(
40
+ /[&<>"]/g,
41
+ (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;"
42
+ );
40
43
  }
41
44
  function firstFileFrame(stack, root) {
42
45
  const re = /(?:file:\/\/\/?)?((?:[A-Za-z]:[\\/]|\/)[^\s():]+):(\d+):(\d+)/g;
43
- let m;
44
46
  const frames = [];
45
- while (m = re.exec(stack)) {
47
+ let m = re.exec(stack);
48
+ while (m) {
46
49
  const raw = m[1];
47
- if (!raw) continue;
48
- if (/^[A-Za-z]:[\\/]/.test(raw) === false && !raw.startsWith("/")) continue;
49
- const file = raw.replace(/\//g, process.platform === "win32" ? "\\" : "/");
50
- if (existsSync(file) && !file.includes("node_modules")) {
51
- frames.push({ file, line: Number(m[2] ?? 0), col: Number(m[3] ?? 0) });
50
+ if (raw && (/^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith("/"))) {
51
+ const file = raw.replace(/\//g, process.platform === "win32" ? "\\" : "/");
52
+ if (existsSync(file) && !file.includes("node_modules")) {
53
+ frames.push({ file, line: Number(m[2] ?? 0), col: Number(m[3] ?? 0) });
54
+ }
52
55
  }
56
+ m = re.exec(stack);
53
57
  }
54
58
  return frames.find((f) => f.file.startsWith(root)) ?? frames[0];
55
59
  }
@@ -174,7 +178,9 @@ async function startDevServer(options) {
174
178
  plugins.unshift(tw());
175
179
  } catch {
176
180
  }
177
- const appCssRel = ["app.css", "styles/app.css", "src/app.css"].find((p) => existsSync2(join(options.root, p)));
181
+ const appCssRel = ["app.css", "styles/app.css", "src/app.css"].find(
182
+ (p) => existsSync2(join(options.root, p))
183
+ );
178
184
  const appCss = appCssRel ? `/${appCssRel}` : void 0;
179
185
  const vite = await createViteServer({
180
186
  root: options.root,
@@ -195,8 +201,14 @@ async function startDevServer(options) {
195
201
  const app = createApp();
196
202
  app.use(fromNodeMiddleware(vite.middlewares));
197
203
  const loadEntries = () => loadApiRoutes(options.root, (id) => ssrLoad(id));
198
- app.use("/api", defineEventHandler((event) => loadEntries().then((e) => createApiHandler(e)(event))));
199
- app.use("/mcp", defineEventHandler((event) => loadEntries().then((e) => createMcpHandler(e)(event))));
204
+ app.use(
205
+ "/api",
206
+ defineEventHandler((event) => loadEntries().then((e) => createApiHandler(e)(event)))
207
+ );
208
+ app.use(
209
+ "/mcp",
210
+ defineEventHandler((event) => loadEntries().then((e) => createMcpHandler(e)(event)))
211
+ );
200
212
  app.use(
201
213
  defineEventHandler(async (event) => {
202
214
  const url = event.path || "/";
@@ -252,9 +264,7 @@ async function startDevServer(options) {
252
264
  port,
253
265
  close: async () => {
254
266
  await vite.close();
255
- await new Promise(
256
- (resolve, reject) => server.close((e) => e ? reject(e) : resolve())
257
- );
267
+ await new Promise((resolve, reject) => server.close((e) => e ? reject(e) : resolve()));
258
268
  }
259
269
  };
260
270
  }
@@ -3,13 +3,13 @@ import {
3
3
  createMcpHandler,
4
4
  expandApiModule,
5
5
  hasMcpRoutes
6
- } from "./chunk-JWYNLP4L.js";
6
+ } from "./chunk-G77MLFUJ.js";
7
7
  import "./chunk-HRJTOSYH.js";
8
8
  import {
9
9
  matchRoute,
10
10
  renderIslandsPage,
11
11
  renderPage
12
- } from "./chunk-VALQEQD3.js";
12
+ } from "./chunk-4FUWZLVW.js";
13
13
  import "./chunk-MZVLRU3R.js";
14
14
 
15
15
  // src/commands/start.ts
@@ -18,8 +18,8 @@ import { join as join2, resolve } from "path";
18
18
  import { defineCommand } from "citty";
19
19
 
20
20
  // src/prod/server.ts
21
- import { createServer as createHttpServer } from "http";
22
21
  import { existsSync, readFileSync, statSync } from "fs";
22
+ import { createServer as createHttpServer } from "http";
23
23
  import { join } from "path";
24
24
  import { pathToFileURL } from "url";
25
25
  import {
@@ -72,7 +72,8 @@ async function startProdServer(options) {
72
72
  if (!file.startsWith(dir) || !existsSync(file) || !statSync(file).isFile()) return;
73
73
  const ext = path.slice(path.lastIndexOf("."));
74
74
  setResponseHeader(event, "Content-Type", MIME[ext] ?? "application/octet-stream");
75
- if (path.startsWith("/assets/")) setResponseHeader(event, "Cache-Control", "public, max-age=31536000, immutable");
75
+ if (path.startsWith("/assets/"))
76
+ setResponseHeader(event, "Cache-Control", "public, max-age=31536000, immutable");
76
77
  return readFileSync(file);
77
78
  })
78
79
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-stack/core",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "The full-stack meta-framework for Alpine.js — CLI and runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "h3": "^1.13.0",
46
46
  "vite": "^6.0.7",
47
47
  "zod": "^4.4.3",
48
- "@apex-stack/kit": "0.1.4",
48
+ "@apex-stack/kit": "0.1.5",
49
49
  "@apex-stack/vite": "0.1.5"
50
50
  },
51
51
  "peerDependencies": {
@@ -1,33 +1,55 @@
1
1
  # {{name}}
2
2
 
3
- An [Apex JS](https://github.com/andrecorugda/apexjs) app — a meta-framework for
4
- Alpine.js that renders on the server and hydrates in the browser.
3
+ An [Apex JS](https://apexjs.site) app — HTML-first, server-rendered, AI-native.
5
4
 
6
- ## Getting started
5
+ ## Commands
7
6
 
8
7
  ```bash
9
- npm install
10
- npm run dev
8
+ npm run dev # dev server → http://localhost:3000
9
+ npm run dev:islands # static-first islands mode (ship ~zero JS)
10
+ npm run build # production build
11
+ npm start # run the production server build
12
+ npm test # run tests (Vitest)
13
+ npm run typecheck # strict type-check
11
14
  ```
12
15
 
13
- Then open [http://localhost:3000](http://localhost:3000).
16
+ > `apex` is a project command — run it via `npm run dev`, or install it globally
17
+ > (`npm i -g @apex-stack/core`) to use `apex dev` directly.
14
18
 
15
- ## Islands mode
16
-
17
- Ship interactive JavaScript only where you need it:
19
+ ## Project structure
18
20
 
19
- ```bash
20
- apex dev --islands
21
+ ```
22
+ pages/ File-based routes (.alpine) — server-rendered, then hydrated.
23
+ layouts/ Shared page shells; default.alpine wraps every page (<slot/>).
24
+ components/ Reusable <PascalCase/> components with scoped styles.
25
+ server/api/ Typed routes (defineApexRoute) — each is a REST endpoint AND an MCP tool.
26
+ services/ Business logic as plain OO classes. Keep routes thin; delegate here.
27
+ shared/ Types/interfaces shared by the backend and the frontend.
28
+ stores/ Global, SSR-safe state — $store.x, reactive across pages/islands.
29
+ composables/ Reusable client logic (useX) for <script client> blocks.
30
+ tests/ Vitest tests. `npm test` runs them.
31
+ db/ Optional: a database + resources. See db/README.md.
32
+ public/ Static assets served as-is.
21
33
  ```
22
34
 
23
- ## Project structure
35
+ ## Conventions (clean code)
36
+
37
+ - **Thin routes → services.** A route/loader validates input and delegates to a service
38
+ class in `services/`. Business logic stays testable in isolation and reusable everywhere.
39
+ - **Types live in `shared/`.** One source of truth; strict TypeScript enforces them across
40
+ backend and frontend — no drift.
41
+ - **Tests by default.** `npm test` runs Vitest (see `tests/greeting.test.ts`).
24
42
 
25
- - `pages/*.alpine` — single-file components. The `<script server>` block runs on
26
- the server; its `loader()` return value becomes the Alpine `x-data` scope.
27
- - `server/api/*.ts` — API routes defined with `defineApexRoute`.
43
+ ## Generators
28
44
 
29
- ## AI-native API
45
+ ```bash
46
+ apex make page about
47
+ apex make component Card
48
+ apex make api todos
49
+ apex make service Billing # → services/BillingService.ts (OO class)
50
+ apex make store cart
51
+ apex make layout marketing
52
+ apex make test billing # → tests/billing.test.ts
53
+ ```
30
54
 
31
- Every route in `server/api/*.ts` is a REST endpoint **and** an MCP tool at the
32
- same time. Set `mcp: true` on a route (see `server/api/hello.ts`) and it is
33
- automatically exposed to AI agents at the `/mcp` endpoint — no extra wiring.
55
+ Full docs: https://apexjs.site
@@ -0,0 +1,15 @@
1
+ <template x-data="{ count: Number(start) }">
2
+ <button class="counter" @click="count++" x-text="label + ': ' + count"></button>
3
+ </template>
4
+
5
+ <style scoped>
6
+ .counter {
7
+ padding: 0.45rem 0.9rem;
8
+ border: 1px solid #6366f1;
9
+ border-radius: 0.5rem;
10
+ background: #eef2ff;
11
+ color: #3730a3;
12
+ cursor: pointer;
13
+ font: inherit;
14
+ }
15
+ </style>
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Reusable client logic. Import it in a <script client> block and use it in x-data:
3
+ *
4
+ * <script client> import { useToggle } from '../composables/useToggle' </script>
5
+ * <template x-data="useToggle(true)"> <button @click="toggle()" x-text="on"></button> </template>
6
+ */
7
+ export function useToggle(initial = false) {
8
+ return {
9
+ on: initial,
10
+ toggle() {
11
+ this.on = !this.on
12
+ },
13
+ }
14
+ }
@@ -0,0 +1,18 @@
1
+ # db/
2
+
3
+ Data is opt-in. To add a database with resources that are REST **and** MCP by default:
4
+
5
+ ```bash
6
+ npm i @apex-stack/data @libsql/client # install only the driver you use
7
+ ```
8
+
9
+ Then add `db/schema.ts` (Drizzle tables), `db/index.ts` (`createDb` + `applyMigrations`),
10
+ and a resource in `server/api/*.ts`:
11
+
12
+ ```ts
13
+ import { defineResource } from '@apex-stack/data'
14
+ export default defineResource('posts', { db, table: schema.posts, insert: { title: z.string() } })
15
+ ```
16
+
17
+ That one line gives you `GET/POST/PATCH/DELETE /api/posts` plus the MCP tools
18
+ `posts_list/get/create/update/delete`. See https://apexjs.site/data.html
@@ -0,0 +1,16 @@
1
+ <template>
2
+ <header class="site-header">
3
+ <a class="brand" href="/">{{name}}</a>
4
+ </header>
5
+ <main>
6
+ <slot></slot>
7
+ </main>
8
+ <footer class="site-footer">Built with Apex JS</footer>
9
+ </template>
10
+
11
+ <style scoped>
12
+ .site-header { padding: 1rem 1.5rem; border-bottom: 1px solid #e2e8f0; }
13
+ .brand { font-weight: 700; color: #2563eb; text-decoration: none; }
14
+ main { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.5rem; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; line-height: 1.6; color: #1e293b; }
15
+ .site-footer { padding: 1.5rem; text-align: center; color: #64748b; font-size: 0.9rem; border-top: 1px solid #e2e8f0; }
16
+ </style>
@@ -4,11 +4,20 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "apex dev",
7
- "dev:islands": "apex dev --islands"
7
+ "dev:islands": "apex dev --islands",
8
+ "build": "apex build",
9
+ "start": "apex start",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest",
12
+ "typecheck": "tsc --noEmit"
8
13
  },
9
14
  "dependencies": {
10
- "alpinejs": "^3.14.8",
11
15
  "@apex-stack/core": "latest",
16
+ "alpinejs": "^3.14.8",
12
17
  "zod": "^4.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^5.6.0",
21
+ "vitest": "^3.0.0"
13
22
  }
14
23
  }
@@ -1,107 +1,38 @@
1
1
  <script server lang="ts">
2
2
  export function loader() {
3
3
  return {
4
- title: 'Welcome to Apex JS',
4
+ title: 'Welcome to {{name}}',
5
5
  tagline: 'The meta-framework for Alpine.js — server-rendered, then hydrated.',
6
6
  }
7
7
  }
8
8
  </script>
9
9
 
10
10
  <template x-data="{ open: false }">
11
- <main>
12
- <h1 x-text="title"></h1>
13
- <p class="tagline" x-text="tagline"></p>
11
+ <h1 x-text="title"></h1>
12
+ <p class="tagline" x-text="tagline"></p>
14
13
 
15
- <section class="card">
16
- <p>
17
- This page was rendered on the server from
18
- <code>pages/index.alpine</code>, then hydrated by Alpine in the browser.
19
- Edit it and save to see your changes live.
20
- </p>
21
-
22
- <button type="button" @click="open = !open" x-text="open ? 'Hide the details' : 'Show me how'"></button>
23
-
24
- <div x-show="open" x-transition class="details">
25
- <p>
26
- The <code>loader()</code> in the server block runs on the server and
27
- hands its data straight to Alpine's <code>x-data</code> scope — no
28
- fetch, no boilerplate.
29
- </p>
30
- <p>
31
- Next, open <code>server/api/hello.ts</code>: it's a REST endpoint
32
- <em>and</em> an MCP tool at <code>/mcp</code> at the same time.
33
- </p>
34
- </div>
35
- </section>
36
-
37
- <p class="hint">
38
- Run <code>apex dev --islands</code> to ship interactive islands only where
39
- you need them.
14
+ <section class="card">
15
+ <p>
16
+ This was rendered on the server from <code>pages/index.alpine</code>, wrapped in
17
+ <code>layouts/default.alpine</code>, then hydrated by Alpine. Edit and save to see it live.
40
18
  </p>
41
- </main>
19
+ <button type="button" @click="open = !open" x-text="open ? 'Hide' : 'Show me how'"></button>
20
+ <div x-show="open" x-transition class="details">
21
+ <p>The <code>loader()</code> runs on the server and hands its data to Alpine's <code>x-data</code>.</p>
22
+ <p>Open <code>server/api/hello.ts</code>: a thin route → <code>GreetingService</code>, exposed as REST <em>and</em> an MCP tool.</p>
23
+ </div>
24
+ </section>
25
+
26
+ <p class="hint">A reusable component that hydrates in the browser:</p>
27
+ <Counter start="0" label="Clicks" />
42
28
  </template>
43
29
 
44
30
  <style scoped>
45
- main {
46
- max-width: 40rem;
47
- margin: 4rem auto;
48
- padding: 0 1.5rem;
49
- font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
50
- line-height: 1.6;
51
- color: #1e293b;
52
- }
53
-
54
- h1 {
55
- color: #2563eb;
56
- font-size: 2.5rem;
57
- margin-bottom: 0.5rem;
58
- }
59
-
60
- .tagline {
61
- font-size: 1.15rem;
62
- color: #475569;
63
- margin-top: 0;
64
- }
65
-
66
- .card {
67
- margin-top: 2rem;
68
- padding: 1.5rem;
69
- border: 1px solid #e2e8f0;
70
- border-radius: 0.75rem;
71
- background: #f8fafc;
72
- }
73
-
74
- button {
75
- margin-top: 0.5rem;
76
- padding: 0.6rem 1.1rem;
77
- font-size: 1rem;
78
- font-weight: 600;
79
- color: #fff;
80
- background: #2563eb;
81
- border: none;
82
- border-radius: 0.5rem;
83
- cursor: pointer;
84
- transition: background 0.15s ease;
85
- }
86
-
87
- button:hover {
88
- background: #1d4ed8;
89
- }
90
-
91
- .details {
92
- margin-top: 1rem;
93
- }
94
-
95
- code {
96
- padding: 0.1rem 0.35rem;
97
- font-size: 0.9em;
98
- background: #e2e8f0;
99
- border-radius: 0.35rem;
100
- }
101
-
102
- .hint {
103
- margin-top: 2rem;
104
- font-size: 0.95rem;
105
- color: #64748b;
106
- }
31
+ h1 { color: #2563eb; font-size: 2.25rem; margin-bottom: 0.25rem; }
32
+ .tagline { font-size: 1.1rem; color: #475569; margin-top: 0; }
33
+ .card { margin-top: 1.5rem; padding: 1.25rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
34
+ .card button { margin-top: 0.5rem; padding: 0.55rem 1rem; font-weight: 600; color: #fff; background: #2563eb; border: none; border-radius: 0.5rem; cursor: pointer; }
35
+ .details { margin-top: 1rem; }
36
+ code { padding: 0.1rem 0.35rem; background: #e2e8f0; border-radius: 0.35rem; font-size: 0.9em; }
37
+ .hint { margin-top: 1.5rem; color: #64748b; font-size: 0.95rem; }
107
38
  </style>
File without changes
@@ -1,10 +1,18 @@
1
1
  import { defineApexRoute } from '@apex-stack/core'
2
2
  import { z } from 'zod'
3
+ import { GreetingService } from '../../services/GreetingService'
3
4
 
5
+ const greetings = new GreetingService()
6
+
7
+ /**
8
+ * A route is a thin adapter: validate input, delegate to a service, return the
9
+ * result. Because `mcp: true`, this is ALSO an MCP tool named "hello" at /mcp —
10
+ * one definition, REST + AI-callable.
11
+ */
4
12
  export default defineApexRoute({
5
13
  method: 'GET',
6
- description: 'Say hello to someone by name',
14
+ description: 'Greet someone by name',
7
15
  input: { name: z.string() },
8
16
  mcp: true,
9
- handler: ({ input }) => ({ message: `Hello, ${input.name}!` }),
17
+ handler: ({ input }) => greetings.greet(input.name),
10
18
  })
@@ -0,0 +1,12 @@
1
+ import type { Greeting } from '../shared/types'
2
+
3
+ /**
4
+ * A service holds business logic as a plain class — testable in isolation and
5
+ * reusable from routes, page loaders, and jobs. Keep routes/loaders thin: they
6
+ * validate input and delegate to a service. This is the clean-code backbone.
7
+ */
8
+ export class GreetingService {
9
+ greet(name: string): Greeting {
10
+ return { message: `Hello, ${name}!`, at: new Date().toISOString() }
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared types — the single source of truth for shapes used across the app,
3
+ * on the BACKEND (routes, services) and the FRONTEND. Import from '../shared/types'.
4
+ *
5
+ * Defining types here (instead of inline) is what keeps a growing codebase clean:
6
+ * one place to change a shape, and the compiler enforces it everywhere it's used.
7
+ */
8
+ export interface Greeting {
9
+ message: string
10
+ at: string
11
+ }
@@ -0,0 +1,9 @@
1
+ import { defineStore } from '@apex-stack/core'
2
+
3
+ // Global, SSR-safe state shared across pages, components, and islands: $store.ui
4
+ export default defineStore('ui', () => ({
5
+ menuOpen: false,
6
+ toggleMenu() {
7
+ this.menuOpen = !this.menuOpen
8
+ },
9
+ }))
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { GreetingService } from '../services/GreetingService'
3
+
4
+ // Services are plain classes, so they test in isolation — no server needed.
5
+ // Generate more with: apex make test <name>
6
+ describe('GreetingService', () => {
7
+ it('greets by name', () => {
8
+ const g = new GreetingService().greet('Apex')
9
+ expect(g.message).toBe('Hello, Apex!')
10
+ expect(typeof g.at).toBe('string')
11
+ })
12
+ })
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "noImplicitOverride": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "verbatimModuleSyntax": true,
12
+ "noEmit": true
13
+ },
14
+ "include": ["server", "services", "shared", "stores", "composables", "tests", "db"]
15
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['tests/**/*.test.ts'],
6
+ },
7
+ })