@apex-stack/core 0.5.0 → 0.6.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/dist/{build-VHS6KZBK.js → build-XUDXRQ4W.js} +3 -3
- package/dist/{chunk-XDKJO6ZC.js → chunk-DVNFDYEO.js} +1 -1
- package/dist/{chunk-JLIAISWM.js → chunk-PMLGY6Z3.js} +14 -14
- package/dist/cli.js +5 -5
- package/dist/client.js +1 -1
- package/dist/{dev-G7HPP6KW.js → dev-74CABXDP.js} +1 -1
- package/dist/index.d.ts +25 -25
- package/dist/index.js +1 -1
- package/dist/{server-PTHGOE42.js → server-VQSL2KPO.js} +18 -4
- package/dist/{start-3O3E43PT.js → start-TBG2TEOE.js} +2 -2
- package/dist/{upgrade-WC5F5FKY.js → upgrade-GCRSV4IE.js} +1 -1
- package/package.json +3 -3
- package/templates/default/README.md +22 -14
- package/templates/default/components/Badge.alpine +6 -0
- package/templates/default/components/Button.alpine +10 -0
- package/templates/default/components/Card.alpine +6 -0
- package/templates/default/components/Counter.alpine +8 -13
- package/templates/default/layouts/default.alpine +37 -14
- package/templates/default/package.json +2 -1
- package/templates/default/pages/about.alpine +29 -0
- package/templates/default/pages/blog/[slug].alpine +38 -0
- package/templates/default/pages/blog/index.alpine +37 -0
- package/templates/default/pages/index.alpine +55 -26
- package/templates/default/server/api/posts.ts +20 -0
- package/templates/default/services/PostService.ts +51 -0
- package/templates/default/shared/types.ts +9 -4
- package/templates/default/tests/posts.test.ts +22 -0
- package/templates/default/server/api/hello.ts +0 -18
- package/templates/default/services/GreetingService.ts +0 -12
- package/templates/default/tests/greeting.test.ts +0 -12
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
renderPage,
|
|
8
8
|
resolveApexConfig,
|
|
9
9
|
scanPages
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import "./chunk-
|
|
10
|
+
} from "./chunk-DVNFDYEO.js";
|
|
11
|
+
import "./chunk-PMLGY6Z3.js";
|
|
12
12
|
|
|
13
13
|
// src/commands/build.ts
|
|
14
14
|
import { cpSync, existsSync as existsSync3, mkdirSync, readdirSync as readdirSync3, rmSync, writeFileSync } from "fs";
|
|
@@ -18,7 +18,7 @@ import { defineCommand } from "citty";
|
|
|
18
18
|
import { createServer as createViteServer } from "vite";
|
|
19
19
|
|
|
20
20
|
// src/build/buildClient.ts
|
|
21
|
-
import { existsSync,
|
|
21
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
22
22
|
import { join } from "path";
|
|
23
23
|
import { apex } from "@apex-stack/vite";
|
|
24
24
|
import { build } from "vite";
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
// src/store.ts
|
|
2
|
-
function defineStore(name, factory) {
|
|
3
|
-
if (!name || /[^a-zA-Z0-9_$]/.test(name)) {
|
|
4
|
-
throw new Error(`defineStore: invalid store name "${name}" \u2014 use letters, digits, _ or $.`);
|
|
5
|
-
}
|
|
6
|
-
return { __apexStore: true, name, factory };
|
|
7
|
-
}
|
|
8
|
-
function isApexStore(x) {
|
|
9
|
-
return typeof x === "object" && x !== null && x.__apexStore === true;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
1
|
// src/config/runtime.ts
|
|
13
2
|
function defineConfig(config) {
|
|
14
3
|
return config;
|
|
@@ -37,12 +26,23 @@ function env(key, fallback) {
|
|
|
37
26
|
return fallback;
|
|
38
27
|
}
|
|
39
28
|
|
|
29
|
+
// src/store.ts
|
|
30
|
+
function defineStore(name, factory) {
|
|
31
|
+
if (!name || /[^a-zA-Z0-9_$]/.test(name)) {
|
|
32
|
+
throw new Error(`defineStore: invalid store name "${name}" \u2014 use letters, digits, _ or $.`);
|
|
33
|
+
}
|
|
34
|
+
return { __apexStore: true, name, factory };
|
|
35
|
+
}
|
|
36
|
+
function isApexStore(x) {
|
|
37
|
+
return typeof x === "object" && x !== null && x.__apexStore === true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
40
|
export {
|
|
41
|
-
defineStore,
|
|
42
|
-
isApexStore,
|
|
43
41
|
defineConfig,
|
|
44
42
|
setRuntimeConfig,
|
|
45
43
|
clientConfigScript,
|
|
46
44
|
useRuntimeConfig,
|
|
47
|
-
env
|
|
45
|
+
env,
|
|
46
|
+
defineStore,
|
|
47
|
+
isApexStore
|
|
48
48
|
};
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { defineCommand as defineCommand2, runMain } from "citty";
|
|
|
14
14
|
|
|
15
15
|
// src/commands/new.ts
|
|
16
16
|
import { spawn, spawnSync } from "child_process";
|
|
17
|
-
import { cpSync, existsSync,
|
|
17
|
+
import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
18
18
|
import { basename, join, resolve } from "path";
|
|
19
19
|
import { fileURLToPath } from "url";
|
|
20
20
|
import { defineCommand } from "citty";
|
|
@@ -147,13 +147,13 @@ var main = defineCommand2({
|
|
|
147
147
|
},
|
|
148
148
|
subCommands: {
|
|
149
149
|
new: newCommand,
|
|
150
|
-
dev: () => import("./dev-
|
|
151
|
-
build: () => import("./build-
|
|
152
|
-
start: () => import("./start-
|
|
150
|
+
dev: () => import("./dev-74CABXDP.js").then((m) => m.devCommand),
|
|
151
|
+
build: () => import("./build-XUDXRQ4W.js").then((m) => m.buildCommand),
|
|
152
|
+
start: () => import("./start-TBG2TEOE.js").then((m) => m.startCommand),
|
|
153
153
|
make: () => import("./make-VAYO5GWA.js").then((m) => m.makeCommand),
|
|
154
154
|
add: () => import("./add-M3YLIFF5.js").then((m) => m.addCommand),
|
|
155
155
|
theme: () => import("./theme-UUOIV44V.js").then((m) => m.themeCommand),
|
|
156
|
-
upgrade: () => import("./upgrade-
|
|
156
|
+
upgrade: () => import("./upgrade-GCRSV4IE.js").then((m) => m.upgradeCommand),
|
|
157
157
|
migrate: () => import("./migrate-X6LIHMIE.js").then((m) => m.migrateCommand),
|
|
158
158
|
mcp: () => import("./mcp-CH7L4GF3.js").then((m) => m.mcpCommand)
|
|
159
159
|
},
|
package/dist/client.js
CHANGED
|
@@ -24,7 +24,7 @@ var devCommand = defineCommand({
|
|
|
24
24
|
process.stdout.write(banner());
|
|
25
25
|
const sp = spinner(`Starting dev server${args.islands ? " (islands mode)" : ""}\u2026`);
|
|
26
26
|
try {
|
|
27
|
-
const { startDevServer } = await import("./server-
|
|
27
|
+
const { startDevServer } = await import("./server-VQSL2KPO.js");
|
|
28
28
|
const { port: actual } = await startDevServer({ root, port, islands: Boolean(args.islands) });
|
|
29
29
|
sp.succeed("Dev server ready");
|
|
30
30
|
ready([
|
package/dist/index.d.ts
CHANGED
|
@@ -124,31 +124,6 @@ interface ApexResource {
|
|
|
124
124
|
}
|
|
125
125
|
declare function isApexResource(x: unknown): x is ApexResource;
|
|
126
126
|
|
|
127
|
-
type StoreState = Record<string, unknown>;
|
|
128
|
-
interface ApexStore {
|
|
129
|
-
readonly __apexStore: true;
|
|
130
|
-
readonly name: string;
|
|
131
|
-
readonly factory: () => StoreState;
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Define a global, SSR-safe store shared across every page, component, and island.
|
|
135
|
-
*
|
|
136
|
-
* ```ts
|
|
137
|
-
* // stores/cart.ts
|
|
138
|
-
* import { defineStore } from '@apex-stack/core'
|
|
139
|
-
* export default defineStore('cart', () => ({
|
|
140
|
-
* items: [] as string[],
|
|
141
|
-
* get count() { return this.items.length },
|
|
142
|
-
* add(x: string) { this.items.push(x) },
|
|
143
|
-
* }))
|
|
144
|
-
* ```
|
|
145
|
-
*
|
|
146
|
-
* Access it anywhere as `$store.cart` — `$store.cart.count` renders on the server
|
|
147
|
-
* and stays reactive after hydration.
|
|
148
|
-
*/
|
|
149
|
-
declare function defineStore(name: string, factory: () => StoreState): ApexStore;
|
|
150
|
-
declare function isApexStore(x: unknown): x is ApexStore;
|
|
151
|
-
|
|
152
127
|
/** The short-circuit value returned by `ctx.redirect(...)`. */
|
|
153
128
|
interface MiddlewareResult {
|
|
154
129
|
readonly __apexRedirect: true;
|
|
@@ -178,4 +153,29 @@ type Middleware = (ctx: MiddlewareContext) => MiddlewareReturn | Promise<Middlew
|
|
|
178
153
|
/** Author a middleware. Identity function — for types + discoverability. */
|
|
179
154
|
declare function defineMiddleware(fn: Middleware): Middleware;
|
|
180
155
|
|
|
156
|
+
type StoreState = Record<string, unknown>;
|
|
157
|
+
interface ApexStore {
|
|
158
|
+
readonly __apexStore: true;
|
|
159
|
+
readonly name: string;
|
|
160
|
+
readonly factory: () => StoreState;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Define a global, SSR-safe store shared across every page, component, and island.
|
|
164
|
+
*
|
|
165
|
+
* ```ts
|
|
166
|
+
* // stores/cart.ts
|
|
167
|
+
* import { defineStore } from '@apex-stack/core'
|
|
168
|
+
* export default defineStore('cart', () => ({
|
|
169
|
+
* items: [] as string[],
|
|
170
|
+
* get count() { return this.items.length },
|
|
171
|
+
* add(x: string) { this.items.push(x) },
|
|
172
|
+
* }))
|
|
173
|
+
* ```
|
|
174
|
+
*
|
|
175
|
+
* Access it anywhere as `$store.cart` — `$store.cart.count` renders on the server
|
|
176
|
+
* and stays reactive after hydration.
|
|
177
|
+
*/
|
|
178
|
+
declare function defineStore(name: string, factory: () => StoreState): ApexStore;
|
|
179
|
+
declare function isApexStore(x: unknown): x is ApexStore;
|
|
180
|
+
|
|
181
181
|
export { type ApexConfig, type ApexResource, type ApexRoute, type ApexRouteConfig, type ApexRouteHandlerContext, type ApexStore, type HttpMethod, type InferInput, type InferOutput, type Middleware, type MiddlewareContext, type MiddlewareResult, type ResourceRoute, type RuntimeConfig, type StoreState, type TypedApexRoute, defineApexRoute, defineConfig, defineMiddleware, defineStore, env, isApexResource, isApexStore, useRuntimeConfig };
|
package/dist/index.js
CHANGED
|
@@ -16,11 +16,11 @@ import {
|
|
|
16
16
|
renderPage,
|
|
17
17
|
resolveApexConfig,
|
|
18
18
|
scanPages
|
|
19
|
-
} from "./chunk-
|
|
20
|
-
import "./chunk-
|
|
19
|
+
} from "./chunk-DVNFDYEO.js";
|
|
20
|
+
import "./chunk-PMLGY6Z3.js";
|
|
21
21
|
|
|
22
22
|
// src/dev/server.ts
|
|
23
|
-
import { existsSync as existsSync2, readdirSync } from "fs";
|
|
23
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "fs";
|
|
24
24
|
import { createServer as createHttpServer } from "http";
|
|
25
25
|
import { createRequire } from "module";
|
|
26
26
|
import { join } from "path";
|
|
@@ -175,17 +175,31 @@ async function startDevServer(options) {
|
|
|
175
175
|
if (alpine) alias.alpinejs = alpine;
|
|
176
176
|
if (kit) alias["@apex-stack/kit"] = kit;
|
|
177
177
|
const plugins = [apex({ clientRuntime: "@apex-stack/core/client" })];
|
|
178
|
+
let hasTailwind = false;
|
|
178
179
|
try {
|
|
179
180
|
const reqProj = createRequire(join(options.root, "package.json"));
|
|
180
181
|
const twMod = await import(pathToFileURL(reqProj.resolve("@tailwindcss/vite")).href);
|
|
181
182
|
const tw = twMod.default ?? twMod;
|
|
182
183
|
plugins.unshift(tw());
|
|
184
|
+
hasTailwind = true;
|
|
183
185
|
} catch {
|
|
184
186
|
}
|
|
185
187
|
const appCssRel = ["app.css", "styles/app.css", "src/app.css"].find(
|
|
186
188
|
(p) => existsSync2(join(options.root, p))
|
|
187
189
|
);
|
|
188
|
-
|
|
190
|
+
let appCss = appCssRel ? `/${appCssRel}` : void 0;
|
|
191
|
+
if (appCssRel && !hasTailwind) {
|
|
192
|
+
const css = readFileSync2(join(options.root, appCssRel), "utf8");
|
|
193
|
+
if (/@import\s+['"]tailwindcss['"]|@tailwind\b/.test(css)) {
|
|
194
|
+
console.warn(
|
|
195
|
+
`
|
|
196
|
+
\u26A0 ${appCssRel} imports Tailwind, but it isn't installed \u2014 skipping it (styles won't apply).
|
|
197
|
+
Fix: npm i -D tailwindcss @tailwindcss/vite
|
|
198
|
+
`
|
|
199
|
+
);
|
|
200
|
+
appCss = void 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
189
203
|
const vite = await createViteServer({
|
|
190
204
|
root: options.root,
|
|
191
205
|
appType: "custom",
|
|
@@ -11,8 +11,8 @@ import {
|
|
|
11
11
|
matchRoute,
|
|
12
12
|
renderIslandsPage,
|
|
13
13
|
renderPage
|
|
14
|
-
} from "./chunk-
|
|
15
|
-
import "./chunk-
|
|
14
|
+
} from "./chunk-DVNFDYEO.js";
|
|
15
|
+
import "./chunk-PMLGY6Z3.js";
|
|
16
16
|
|
|
17
17
|
// src/commands/start.ts
|
|
18
18
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
|
|
10
10
|
// src/commands/upgrade.ts
|
|
11
11
|
import { spawnSync } from "child_process";
|
|
12
|
-
import { existsSync, mkdirSync,
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
13
|
import { basename, dirname, join, relative, resolve } from "path";
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
15
15
|
import { defineCommand } from "citty";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apex-stack/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "The full-stack meta-framework for Alpine.js — CLI and runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"h3": "^1.13.0",
|
|
48
48
|
"vite": "^6.0.7",
|
|
49
49
|
"zod": "^4.4.3",
|
|
50
|
-
"@apex-stack/kit": "0.
|
|
51
|
-
"@apex-stack/vite": "0.1.
|
|
50
|
+
"@apex-stack/kit": "0.3.0",
|
|
51
|
+
"@apex-stack/vite": "0.1.7",
|
|
52
52
|
"@apex-stack/theme": "0.3.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
An [Apex JS](https://apexjs.site) app — HTML-first, server-rendered, AI-native.
|
|
4
4
|
|
|
5
|
+
This starter is a small, themed demo: a landing page, a blog (list + dynamic
|
|
6
|
+
`[slug]` detail) served from a sample-data service, an About page with SEO, a
|
|
7
|
+
dark-mode toggle, themeable components, and an API route that's also an MCP tool.
|
|
8
|
+
|
|
5
9
|
## Commands
|
|
6
10
|
|
|
7
11
|
```bash
|
|
8
|
-
npm run dev
|
|
9
|
-
npm run dev:islands
|
|
10
|
-
npm run build
|
|
11
|
-
npm start
|
|
12
|
-
npm
|
|
13
|
-
npm run
|
|
12
|
+
npm run dev # dev server → http://localhost:3000
|
|
13
|
+
npm run dev:islands # static-first islands mode (ship ~zero JS)
|
|
14
|
+
npm run build # production build (server target: SSR + dynamic routes + API/MCP)
|
|
15
|
+
npm start # run the production server build
|
|
16
|
+
npm run build:static # static build — prerenders static pages (dynamic routes need the server target)
|
|
17
|
+
npm test # run tests (Vitest)
|
|
18
|
+
npm run typecheck # strict type-check
|
|
14
19
|
```
|
|
15
20
|
|
|
16
21
|
> `apex` is a project command — run it via `npm run dev`, or install it globally
|
|
@@ -21,7 +26,7 @@ npm run typecheck # strict type-check
|
|
|
21
26
|
```
|
|
22
27
|
pages/ File-based routes (.alpine) — server-rendered, then hydrated.
|
|
23
28
|
layouts/ Shared page shells; default.alpine wraps every page (<slot/>).
|
|
24
|
-
components/ Reusable <PascalCase/> components with
|
|
29
|
+
components/ Reusable <PascalCase/> components, themed with Apex tokens (Button, Card, Badge, Counter).
|
|
25
30
|
server/api/ Typed routes (defineApexRoute) — each is a REST endpoint AND an MCP tool.
|
|
26
31
|
services/ Business logic as plain OO classes. Keep routes thin; delegate here.
|
|
27
32
|
shared/ Types/interfaces shared by the backend and the frontend.
|
|
@@ -38,7 +43,7 @@ public/ Static assets served as-is.
|
|
|
38
43
|
class in `services/`. Business logic stays testable in isolation and reusable everywhere.
|
|
39
44
|
- **Types live in `shared/`.** One source of truth; strict TypeScript enforces them across
|
|
40
45
|
backend and frontend — no drift.
|
|
41
|
-
- **Tests by default.** `npm test` runs Vitest (see `tests/
|
|
46
|
+
- **Tests by default.** `npm test` runs Vitest (see `tests/posts.test.ts`).
|
|
42
47
|
|
|
43
48
|
## Generators
|
|
44
49
|
|
|
@@ -53,13 +58,16 @@ apex make middleware auth # → middleware/auth.ts (runs on every request)
|
|
|
53
58
|
apex make test billing # → tests/billing.test.ts
|
|
54
59
|
```
|
|
55
60
|
|
|
56
|
-
## Styling
|
|
61
|
+
## Styling & theming
|
|
57
62
|
|
|
58
|
-
- **
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- **
|
|
62
|
-
`app.css`.
|
|
63
|
+
- **Tailwind + theme, preinstalled.** `app.css` already imports Tailwind and defines the Apex
|
|
64
|
+
theme tokens (`--color-primary`, `--radius-radius`, fonts, and a `dark` variant). Components use
|
|
65
|
+
token classes like `bg-primary` / `text-on-surface` / `rounded-radius`, so they all restyle at once.
|
|
66
|
+
- **Restyle everything:** `apex theme --primary "#4f46e5" --radius 0.5rem` rewrites the managed
|
|
67
|
+
`/* apex-theme */` block in `app.css`. Or design it visually at https://apexjs.site/theme.html.
|
|
68
|
+
- **Add components:** `apex add <name>` copies a themed component into `components/`. Browse them at
|
|
69
|
+
https://apexjs.site/ui.html.
|
|
70
|
+
- **Scoped styles** still work too: a `<style scoped>` block in an `.alpine` file is scoped to it.
|
|
63
71
|
|
|
64
72
|
## Config & environment
|
|
65
73
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!-- Apex component adapted from Penguin UI (MIT). -->
|
|
2
|
+
<template x-data>
|
|
3
|
+
<span class="inline-flex items-center gap-1 rounded-radius border border-outline bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark">
|
|
4
|
+
<slot>Badge</slot>
|
|
5
|
+
</span>
|
|
6
|
+
</template>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<!-- Apex component adapted from Penguin UI (MIT). Styles via theme tokens
|
|
2
|
+
(bg-primary / rounded-radius / …) — needs Tailwind + @apex-stack/theme. -->
|
|
3
|
+
<template x-data>
|
|
4
|
+
<button
|
|
5
|
+
type="button"
|
|
6
|
+
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius bg-primary border border-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-primary-dark dark:border-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark"
|
|
7
|
+
>
|
|
8
|
+
<slot>Button</slot>
|
|
9
|
+
</button>
|
|
10
|
+
</template>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!-- Apex component adapted from Penguin UI (MIT). -->
|
|
2
|
+
<template>
|
|
3
|
+
<div class="flex flex-col gap-4 rounded-radius border border-outline bg-surface-alt p-6 text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark">
|
|
4
|
+
<slot></slot>
|
|
5
|
+
</div>
|
|
6
|
+
</template>
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
+
<!-- A small interactive component, themed with Apex tokens. `start`/`label` are
|
|
2
|
+
props; the count lives in client state and updates on click (hydrated). -->
|
|
1
3
|
<template x-data="{ count: Number(start) }">
|
|
2
|
-
<button
|
|
4
|
+
<button
|
|
5
|
+
type="button"
|
|
6
|
+
class="inline-flex items-center gap-2 rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm font-medium text-on-surface transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:focus-visible:outline-primary-dark"
|
|
7
|
+
@click="count++"
|
|
8
|
+
x-text="label + ': ' + count"
|
|
9
|
+
></button>
|
|
3
10
|
</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>
|
|
@@ -1,16 +1,39 @@
|
|
|
1
|
+
<!-- The default layout wraps every page. It's themed entirely with Apex tokens
|
|
2
|
+
(bg-surface / text-on-surface / dark:*), so restyling the whole app is one
|
|
3
|
+
`apex theme` command away. <slot></slot> is where the page renders. -->
|
|
1
4
|
<template>
|
|
2
|
-
<
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
<div class="flex min-h-svh flex-col bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark">
|
|
6
|
+
<header class="border-b border-outline dark:border-outline-dark">
|
|
7
|
+
<nav
|
|
8
|
+
class="mx-auto flex max-w-5xl items-center gap-2 px-6 py-4"
|
|
9
|
+
x-data="{ dark: false }"
|
|
10
|
+
x-init="dark = localStorage.getItem('theme') === 'dark'; document.documentElement.classList.toggle('dark', dark)"
|
|
11
|
+
>
|
|
12
|
+
<a href="/" class="mr-auto font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
|
13
|
+
{{name}}
|
|
14
|
+
</a>
|
|
15
|
+
<a href="/" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Home</a>
|
|
16
|
+
<a href="/blog" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Blog</a>
|
|
17
|
+
<a href="/about" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">About</a>
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
aria-label="Toggle dark mode"
|
|
21
|
+
class="ml-1 rounded-radius border border-outline p-2 text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt"
|
|
22
|
+
@click="dark = !dark; document.documentElement.classList.toggle('dark', dark); localStorage.setItem('theme', dark ? 'dark' : 'light')"
|
|
23
|
+
>
|
|
24
|
+
<svg x-show="!dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
|
25
|
+
<svg x-cloak x-show="dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
26
|
+
</button>
|
|
27
|
+
</nav>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<main class="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
|
|
31
|
+
<slot></slot>
|
|
32
|
+
</main>
|
|
10
33
|
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
</
|
|
34
|
+
<footer class="border-t border-outline px-6 py-6 text-center text-sm text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
|
|
35
|
+
Built with
|
|
36
|
+
<a href="https://apexjs.site" class="font-medium text-primary dark:text-primary-dark">Apex JS</a>
|
|
37
|
+
</footer>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script server lang="ts">
|
|
2
|
+
export function loader() {
|
|
3
|
+
return {}
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// SEO for this page — server-rendered <title> + meta description.
|
|
7
|
+
export function head() {
|
|
8
|
+
return {
|
|
9
|
+
title: 'About · {{name}}',
|
|
10
|
+
meta: [{ name: 'description', content: 'What this Apex JS starter demonstrates.' }],
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template x-data>
|
|
16
|
+
<h1 class="font-title text-3xl font-extrabold text-on-surface-strong dark:text-on-surface-dark-strong">About this starter</h1>
|
|
17
|
+
<p class="mt-3 max-w-2xl">This app was scaffolded with <code>create-apexjs</code>. It's a small tour of what Apex gives you out of the box:</p>
|
|
18
|
+
|
|
19
|
+
<ul class="mt-6 grid gap-3 sm:grid-cols-2">
|
|
20
|
+
<li><Card><b class="text-on-surface-strong dark:text-on-surface-dark-strong">File routing</b><span class="text-sm">Pages, dynamic <code>[slug]</code>, and a shared layout — see <code>pages/</code>.</span></Card></li>
|
|
21
|
+
<li><Card><b class="text-on-surface-strong dark:text-on-surface-dark-strong">SSR + hydration</b><span class="text-sm">Loaders render real HTML; Alpine hydrates it with no flash.</span></Card></li>
|
|
22
|
+
<li><Card><b class="text-on-surface-strong dark:text-on-surface-dark-strong">Themed components</b><span class="text-sm">Add more with <code>apex add <name></code>; restyle with <code>apex theme</code>.</span></Card></li>
|
|
23
|
+
<li><Card><b class="text-on-surface-strong dark:text-on-surface-dark-strong">AI-native API</b><span class="text-sm">Routes double as MCP tools at <code>/mcp</code>.</span></Card></li>
|
|
24
|
+
</ul>
|
|
25
|
+
|
|
26
|
+
<p class="mt-8 text-sm text-on-surface/80 dark:text-on-surface-dark/80">
|
|
27
|
+
Edit any file under <code>pages/</code>, <code>components/</code>, or <code>app.css</code> and save — the dev server updates instantly.
|
|
28
|
+
</p>
|
|
29
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script server lang="ts">
|
|
2
|
+
import { PostService } from '../../services/PostService'
|
|
3
|
+
|
|
4
|
+
const posts = new PostService()
|
|
5
|
+
|
|
6
|
+
// Dynamic route: pages/blog/[slug].alpine → /blog/:slug. The matched param
|
|
7
|
+
// arrives in loader({ params }).
|
|
8
|
+
export function loader({ params }: { params: Record<string, string> }) {
|
|
9
|
+
const post = posts.bySlug(params.slug)
|
|
10
|
+
return { post: post ?? null }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function head({ data }: { data: { post: { title: string } | null } }) {
|
|
14
|
+
return { title: `${data.post ? data.post.title : 'Not found'} · {{name}}` }
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template x-data>
|
|
19
|
+
<a href="/blog" class="text-sm text-primary hover:opacity-75 dark:text-primary-dark">← Back to blog</a>
|
|
20
|
+
|
|
21
|
+
<template x-if="post">
|
|
22
|
+
<article class="mt-4">
|
|
23
|
+
<div class="flex items-center gap-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
|
24
|
+
<Badge><span x-text="post.author"></span></Badge>
|
|
25
|
+
<span x-text="post.date"></span>
|
|
26
|
+
</div>
|
|
27
|
+
<h1 class="mt-3 font-title text-3xl font-extrabold text-on-surface-strong dark:text-on-surface-dark-strong" x-text="post.title"></h1>
|
|
28
|
+
<p class="mt-4 text-lg leading-relaxed" x-text="post.body"></p>
|
|
29
|
+
</article>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<template x-if="!post">
|
|
33
|
+
<div class="mt-8">
|
|
34
|
+
<h1 class="font-title text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">Post not found</h1>
|
|
35
|
+
<p class="mt-2 text-on-surface/80 dark:text-on-surface-dark/80">That post doesn't exist. Try the <a href="/blog" class="text-primary dark:text-primary-dark">blog index</a>.</p>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script server lang="ts">
|
|
2
|
+
import { PostService } from '../../services/PostService'
|
|
3
|
+
|
|
4
|
+
const posts = new PostService()
|
|
5
|
+
|
|
6
|
+
export function loader() {
|
|
7
|
+
return { posts: posts.all() }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// head() drives <title> and meta tags — SEO, rendered on the server.
|
|
11
|
+
export function head() {
|
|
12
|
+
return { title: 'Blog · {{name}}' }
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template x-data>
|
|
17
|
+
<h1 class="font-title text-3xl font-extrabold text-on-surface-strong dark:text-on-surface-dark-strong">Blog</h1>
|
|
18
|
+
<p class="mt-2 text-on-surface/80 dark:text-on-surface-dark/80">Sample posts served from a service — no database required.</p>
|
|
19
|
+
|
|
20
|
+
<!-- Apex components work inside x-for/x-if — <Card> here is re-created per item
|
|
21
|
+
on the client, fully styled. That's the "Alpine Extreme" bit. -->
|
|
22
|
+
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
|
23
|
+
<template x-for="p in posts" :key="p.slug">
|
|
24
|
+
<a :href="'/blog/' + p.slug" class="block transition hover:opacity-75">
|
|
25
|
+
<Card>
|
|
26
|
+
<div class="flex items-center gap-2 text-xs text-on-surface/70 dark:text-on-surface-dark/70">
|
|
27
|
+
<span x-text="p.author"></span>
|
|
28
|
+
<span>·</span>
|
|
29
|
+
<span x-text="p.date"></span>
|
|
30
|
+
</div>
|
|
31
|
+
<h2 class="font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong" x-text="p.title"></h2>
|
|
32
|
+
<p class="text-sm" x-text="p.excerpt"></p>
|
|
33
|
+
</Card>
|
|
34
|
+
</a>
|
|
35
|
+
</template>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
@@ -1,38 +1,67 @@
|
|
|
1
1
|
<script server lang="ts">
|
|
2
|
+
import { PostService } from '../services/PostService'
|
|
3
|
+
|
|
4
|
+
const posts = new PostService()
|
|
5
|
+
|
|
6
|
+
// Runs on the server. Its return value becomes the page's x-data — so the
|
|
7
|
+
// HTML is rendered from real data before a single byte of JS runs.
|
|
2
8
|
export function loader() {
|
|
3
9
|
return {
|
|
4
10
|
title: 'Welcome to {{name}}',
|
|
5
|
-
tagline: '
|
|
11
|
+
tagline: 'A full-stack, server-rendered app built on Alpine.js — themed, typed, and AI-native.',
|
|
12
|
+
recent: posts.recent(3),
|
|
6
13
|
}
|
|
7
14
|
}
|
|
8
15
|
</script>
|
|
9
16
|
|
|
10
|
-
<template x-data="{
|
|
11
|
-
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
</p>
|
|
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>
|
|
17
|
+
<template x-data="{ show: false }">
|
|
18
|
+
<!-- Hero -->
|
|
19
|
+
<section class="py-8 text-center">
|
|
20
|
+
<h1 class="font-title text-4xl font-extrabold tracking-tight text-on-surface-strong sm:text-5xl dark:text-on-surface-dark-strong" x-text="title"></h1>
|
|
21
|
+
<p class="mx-auto mt-4 max-w-2xl text-lg" x-text="tagline"></p>
|
|
22
|
+
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
|
23
|
+
<a href="/blog"><Button>Read the blog</Button></a>
|
|
24
|
+
<a href="https://apexjs.site" class="inline-flex items-center justify-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:opacity-75 dark:border-outline-dark dark:text-on-surface-dark">Docs</a>
|
|
23
25
|
</div>
|
|
24
26
|
</section>
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
28
|
+
<!-- Feature cards -->
|
|
29
|
+
<section class="mt-6 grid gap-4 sm:grid-cols-3">
|
|
30
|
+
<Card>
|
|
31
|
+
<h3 class="font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">Server-rendered</h3>
|
|
32
|
+
<p class="text-sm">Every page is real HTML from a <code>loader()</code>, then hydrated by Alpine — fast and indexable.</p>
|
|
33
|
+
</Card>
|
|
34
|
+
<Card>
|
|
35
|
+
<h3 class="font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">Themed</h3>
|
|
36
|
+
<p class="text-sm">All components use theme tokens. Restyle the whole app with one <code>apex theme</code> command.</p>
|
|
37
|
+
</Card>
|
|
38
|
+
<Card>
|
|
39
|
+
<h3 class="font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">AI-native</h3>
|
|
40
|
+
<p class="text-sm">Every typed API route is also an MCP tool your AI can call. See <code>server/api/posts.ts</code>.</p>
|
|
41
|
+
</Card>
|
|
42
|
+
</section>
|
|
29
43
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
</
|
|
44
|
+
<!-- Recent posts, from the loader -->
|
|
45
|
+
<section class="mt-10">
|
|
46
|
+
<h2 class="font-title text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">Recent posts</h2>
|
|
47
|
+
<ul class="mt-3 divide-y divide-outline dark:divide-outline-dark">
|
|
48
|
+
<template x-for="p in recent" :key="p.slug">
|
|
49
|
+
<li class="py-3">
|
|
50
|
+
<a :href="'/blog/' + p.slug" class="font-medium text-primary hover:opacity-75 dark:text-primary-dark" x-text="p.title"></a>
|
|
51
|
+
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80" x-text="p.excerpt"></p>
|
|
52
|
+
</li>
|
|
53
|
+
</template>
|
|
54
|
+
</ul>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<!-- A hydrated, interactive component -->
|
|
58
|
+
<section class="mt-10 flex flex-col items-start gap-3">
|
|
59
|
+
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">Hydrated in the browser — click it:</p>
|
|
60
|
+
<Counter start="0" label="Clicks" />
|
|
61
|
+
<button type="button" class="text-sm text-primary hover:opacity-75 dark:text-primary-dark" @click="show = !show" x-text="show ? 'Hide details' : 'How does this work?'"></button>
|
|
62
|
+
<p x-show="show" x-transition class="max-w-2xl text-sm text-on-surface/80 dark:text-on-surface-dark/80">
|
|
63
|
+
This page is <code>pages/index.alpine</code>, wrapped in <code>layouts/default.alpine</code>.
|
|
64
|
+
The server ran <code>loader()</code>, rendered the HTML, and Alpine took over in the browser — the counter and this toggle are proof.
|
|
65
|
+
</p>
|
|
66
|
+
</section>
|
|
67
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineApexRoute } from '@apex-stack/core'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { PostService } from '../../services/PostService'
|
|
4
|
+
|
|
5
|
+
const posts = new PostService()
|
|
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 "posts" at /mcp —
|
|
10
|
+
* one definition, REST + AI-callable. Try:
|
|
11
|
+
* curl "http://localhost:3000/api/posts"
|
|
12
|
+
* curl "http://localhost:3000/api/posts?slug=hello-apex"
|
|
13
|
+
*/
|
|
14
|
+
export default defineApexRoute({
|
|
15
|
+
method: 'GET',
|
|
16
|
+
description: 'List blog posts, or fetch one by slug',
|
|
17
|
+
input: { slug: z.string().optional() },
|
|
18
|
+
mcp: true,
|
|
19
|
+
handler: ({ input }) => (input.slug ? (posts.bySlug(input.slug) ?? null) : posts.all()),
|
|
20
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Post } 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. Here it stands in for a database
|
|
6
|
+
* with in-memory sample data; swap the array for a real query (see the `db/`
|
|
7
|
+
* folder and `defineResource`) and nothing else in the app changes.
|
|
8
|
+
*/
|
|
9
|
+
const POSTS: Post[] = [
|
|
10
|
+
{
|
|
11
|
+
slug: 'hello-apex',
|
|
12
|
+
title: 'Hello, Apex',
|
|
13
|
+
excerpt: 'Why Alpine deserved a full-stack meta-framework.',
|
|
14
|
+
author: 'Ada Lovelace',
|
|
15
|
+
date: '2026-02-01',
|
|
16
|
+
body: 'Apex renders your pages on the server as real, indexable HTML, then Alpine hydrates them in the browser — no client-side framework tax, no flash. This whole app is server-rendered from .alpine files and made interactive by Alpine.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
slug: 'ssr-then-hydrate',
|
|
20
|
+
title: 'SSR first, hydrate second',
|
|
21
|
+
excerpt: 'Real HTML on the first byte. Interactivity right after.',
|
|
22
|
+
author: 'Grace Hopper',
|
|
23
|
+
date: '2026-02-08',
|
|
24
|
+
body: 'Each page has a loader() that runs on the server. Its return value is handed to Alpine as x-data, so the markup you see is the markup search engines and users get instantly — then the same state powers client interactivity.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
slug: 'routes-are-tools',
|
|
28
|
+
title: 'Every route is also an MCP tool',
|
|
29
|
+
excerpt: 'Ship an API your AI can call — from one definition.',
|
|
30
|
+
author: 'Alan Turing',
|
|
31
|
+
date: '2026-02-15',
|
|
32
|
+
body: 'Open server/api/posts.ts: one defineApexRoute is a validated REST endpoint AND an MCP tool at /mcp. Point an AI client at it and it can list your posts with no extra glue.',
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
export class PostService {
|
|
37
|
+
/** All posts, newest first. */
|
|
38
|
+
all(): Post[] {
|
|
39
|
+
return [...POSTS].sort((a, b) => b.date.localeCompare(a.date))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The N most recent posts. */
|
|
43
|
+
recent(n: number): Post[] {
|
|
44
|
+
return this.all().slice(0, n)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A single post by its slug, or undefined. */
|
|
48
|
+
bySlug(slug: string): Post | undefined {
|
|
49
|
+
return POSTS.find((p) => p.slug === slug)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared types — the single source of truth for shapes used across the app,
|
|
3
|
-
* on the BACKEND (routes, services) and the FRONTEND
|
|
3
|
+
* on the BACKEND (routes, services) and the FRONTEND (pages, components).
|
|
4
|
+
* Import from '../shared/types'.
|
|
4
5
|
*
|
|
5
6
|
* Defining types here (instead of inline) is what keeps a growing codebase clean:
|
|
6
7
|
* one place to change a shape, and the compiler enforces it everywhere it's used.
|
|
7
8
|
*/
|
|
8
|
-
export interface
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export interface Post {
|
|
10
|
+
slug: string
|
|
11
|
+
title: string
|
|
12
|
+
excerpt: string
|
|
13
|
+
author: string
|
|
14
|
+
date: string
|
|
15
|
+
body: string
|
|
11
16
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { PostService } from '../services/PostService'
|
|
3
|
+
|
|
4
|
+
// Services are plain classes → unit-test them in isolation, no server needed.
|
|
5
|
+
describe('PostService', () => {
|
|
6
|
+
const posts = new PostService()
|
|
7
|
+
|
|
8
|
+
it('lists posts newest first', () => {
|
|
9
|
+
const dates = posts.all().map((p) => p.date)
|
|
10
|
+
expect(dates.length).toBeGreaterThan(0)
|
|
11
|
+
expect(dates).toEqual([...dates].sort((a, b) => b.localeCompare(a)))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('limits recent()', () => {
|
|
15
|
+
expect(posts.recent(2)).toHaveLength(2)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('finds a post by slug', () => {
|
|
19
|
+
expect(posts.bySlug('hello-apex')?.title).toBe('Hello, Apex')
|
|
20
|
+
expect(posts.bySlug('nope')).toBeUndefined()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { defineApexRoute } from '@apex-stack/core'
|
|
2
|
-
import { z } from 'zod'
|
|
3
|
-
import { GreetingService } from '../../services/GreetingService'
|
|
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
|
-
*/
|
|
12
|
-
export default defineApexRoute({
|
|
13
|
-
method: 'GET',
|
|
14
|
-
description: 'Greet someone by name',
|
|
15
|
-
input: { name: z.string() },
|
|
16
|
-
mcp: true,
|
|
17
|
-
handler: ({ input }) => greetings.greet(input.name),
|
|
18
|
-
})
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
})
|