@apex-stack/core 0.5.0 → 0.7.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-NA6JEP3F.js} +3 -3
- package/dist/{chunk-XDKJO6ZC.js → chunk-7CBGVRBB.js} +183 -159
- package/dist/chunk-P6KQSPAV.js +95 -0
- package/dist/{chunk-JLIAISWM.js → chunk-PMLGY6Z3.js} +14 -14
- package/dist/cli.js +6 -6
- package/dist/client.js +1 -1
- package/dist/{dev-G7HPP6KW.js → dev-6SH5IYNT.js} +1 -1
- package/dist/index.d.ts +25 -25
- package/dist/index.js +1 -1
- package/dist/server-SSHQHMWY.js +479 -0
- package/dist/{start-3O3E43PT.js → start-7HSIYO6C.js} +2 -2
- package/dist/{upgrade-WC5F5FKY.js → upgrade-KGUW3C3O.js} +2 -2
- 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 +39 -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 +59 -25
- package/templates/default/public/favicon.svg +11 -0
- 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/vscode/apex-alpine.vsix +0 -0
- package/vscode/version.txt +1 -0
- package/dist/chunk-CHBSGOB3.js +0 -42
- package/dist/server-PTHGOE42.js +0 -307
- 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
|
@@ -1,16 +1,41 @@
|
|
|
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') ? localStorage.getItem('theme') === 'dark' : (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); document.documentElement.classList.toggle('dark', dark)"
|
|
11
|
+
client:load
|
|
12
|
+
>
|
|
13
|
+
<a href="/" class="mr-auto flex items-center gap-2 font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
|
14
|
+
<img src="/favicon.svg" alt="" width="24" height="24" class="size-6" />
|
|
15
|
+
{{name}}
|
|
16
|
+
</a>
|
|
17
|
+
<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>
|
|
18
|
+
<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>
|
|
19
|
+
<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>
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
aria-label="Toggle dark mode"
|
|
23
|
+
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"
|
|
24
|
+
@click="dark = !dark; document.documentElement.classList.toggle('dark', dark); localStorage.setItem('theme', dark ? 'dark' : 'light')"
|
|
25
|
+
>
|
|
26
|
+
<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>
|
|
27
|
+
<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>
|
|
28
|
+
</button>
|
|
29
|
+
</nav>
|
|
30
|
+
</header>
|
|
31
|
+
|
|
32
|
+
<main class="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
|
|
33
|
+
<slot></slot>
|
|
34
|
+
</main>
|
|
10
35
|
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
</
|
|
36
|
+
<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">
|
|
37
|
+
Built with
|
|
38
|
+
<a href="https://apexjs.site" class="font-medium text-primary dark:text-primary-dark">Apex JS</a>
|
|
39
|
+
</footer>
|
|
40
|
+
</div>
|
|
41
|
+
</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,72 @@
|
|
|
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
|
-
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.
|
|
17
|
+
<template x-data>
|
|
18
|
+
<!-- Hero -->
|
|
19
|
+
<section class="flex flex-col items-center py-10 text-center sm:py-16">
|
|
20
|
+
<img src="/favicon.svg" alt="Apex JS" width="72" height="72" class="size-16 sm:size-20 drop-shadow" />
|
|
21
|
+
<p class="mt-5 inline-flex items-center gap-2 rounded-full border border-outline px-3 py-1 text-xs font-medium text-on-surface/80 dark:border-outline-dark dark:text-on-surface-dark/80">
|
|
22
|
+
Built with <span class="font-title font-bold text-on-surface-strong dark:text-on-surface-dark-strong">Apex JS</span>
|
|
18
23
|
</p>
|
|
19
|
-
<
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
<
|
|
24
|
+
<h1 class="mt-5 font-title text-4xl font-extrabold tracking-tight text-on-surface-strong sm:text-6xl dark:text-on-surface-dark-strong" x-text="title"></h1>
|
|
25
|
+
<p class="mx-auto mt-4 max-w-2xl text-lg" x-text="tagline"></p>
|
|
26
|
+
<div class="mt-7 flex flex-wrap justify-center gap-3">
|
|
27
|
+
<a href="/blog"><Button>Read the blog</Button></a>
|
|
28
|
+
<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
29
|
</div>
|
|
24
30
|
</section>
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
32
|
+
<!-- Feature cards -->
|
|
33
|
+
<section class="mt-6 grid gap-4 sm:grid-cols-3">
|
|
34
|
+
<Card>
|
|
35
|
+
<h3 class="font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">Server-rendered</h3>
|
|
36
|
+
<p class="text-sm">Every page is real HTML from a <code>loader()</code>, then hydrated by Alpine — fast and indexable.</p>
|
|
37
|
+
</Card>
|
|
38
|
+
<Card>
|
|
39
|
+
<h3 class="font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">Themed</h3>
|
|
40
|
+
<p class="text-sm">All components use theme tokens. Restyle the whole app with one <code>apex theme</code> command.</p>
|
|
41
|
+
</Card>
|
|
42
|
+
<Card>
|
|
43
|
+
<h3 class="font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">AI-native</h3>
|
|
44
|
+
<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>
|
|
45
|
+
</Card>
|
|
46
|
+
</section>
|
|
29
47
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
</
|
|
48
|
+
<!-- Recent posts, from the loader -->
|
|
49
|
+
<section class="mt-10">
|
|
50
|
+
<h2 class="font-title text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">Recent posts</h2>
|
|
51
|
+
<ul class="mt-3 divide-y divide-outline dark:divide-outline-dark">
|
|
52
|
+
<template x-for="p in recent" :key="p.slug">
|
|
53
|
+
<li class="py-3">
|
|
54
|
+
<a :href="'/blog/' + p.slug" class="font-medium text-primary hover:opacity-75 dark:text-primary-dark" x-text="p.title"></a>
|
|
55
|
+
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80" x-text="p.excerpt"></p>
|
|
56
|
+
</li>
|
|
57
|
+
</template>
|
|
58
|
+
</ul>
|
|
59
|
+
</section>
|
|
60
|
+
|
|
61
|
+
<!-- A hydrated, interactive island. `client:load` hydrates it immediately in
|
|
62
|
+
islands mode; in default mode the whole page hydrates anyway. -->
|
|
63
|
+
<section class="mt-10 flex flex-col items-start gap-3" x-data="{ show: false }" client:load>
|
|
64
|
+
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">Hydrated in the browser — click it:</p>
|
|
65
|
+
<Counter start="0" label="Clicks" />
|
|
66
|
+
<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>
|
|
67
|
+
<p x-show="show" x-transition class="max-w-2xl text-sm text-on-surface/80 dark:text-on-surface-dark/80">
|
|
68
|
+
This page is <code>pages/index.alpine</code>, wrapped in <code>layouts/default.alpine</code>.
|
|
69
|
+
The server ran <code>loader()</code>, rendered the HTML, and Alpine took over in the browser — the counter and this toggle are proof.
|
|
70
|
+
</p>
|
|
71
|
+
</section>
|
|
72
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Apex">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="apexLit" x1="0" y1="1" x2="1" y2="0">
|
|
4
|
+
<stop offset="0" stop-color="#22d3ee"/>
|
|
5
|
+
<stop offset="1" stop-color="#6366f1"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<!-- Apex mark: an "A" that is also a summit. Left face lit, right face in shadow. -->
|
|
9
|
+
<path d="M32 7 L32 35 L20 56 L4 56 Z" fill="url(#apexLit)"/>
|
|
10
|
+
<path d="M32 7 L60 56 L44 56 L32 35 Z" fill="#6366f1"/>
|
|
11
|
+
</svg>
|
|
@@ -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
|
+
})
|
package/vscode/apex-alpine.vsix
CHANGED
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.3
|
package/dist/chunk-CHBSGOB3.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// src/vscode.ts
|
|
2
|
-
import { spawnSync } from "child_process";
|
|
3
|
-
import { existsSync } from "fs";
|
|
4
|
-
import { createInterface } from "readline";
|
|
5
|
-
import { fileURLToPath } from "url";
|
|
6
|
-
var VSIX = fileURLToPath(new URL("../vscode/apex-alpine.vsix", import.meta.url));
|
|
7
|
-
var WIN = process.platform === "win32";
|
|
8
|
-
function extensionBundled() {
|
|
9
|
-
return existsSync(VSIX);
|
|
10
|
-
}
|
|
11
|
-
function hasCodeCli() {
|
|
12
|
-
try {
|
|
13
|
-
return spawnSync("code", ["--version"], { stdio: "ignore", shell: WIN }).status === 0;
|
|
14
|
-
} catch {
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
function installExtension() {
|
|
19
|
-
if (!existsSync(VSIX)) return false;
|
|
20
|
-
return spawnSync("code", ["--install-extension", VSIX, "--force"], { stdio: "inherit", shell: WIN }).status === 0;
|
|
21
|
-
}
|
|
22
|
-
function promptYesNo(question, def = true) {
|
|
23
|
-
if (!process.stdin.isTTY) return Promise.resolve(def);
|
|
24
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
25
|
-
return new Promise((resolve) => {
|
|
26
|
-
rl.question(`${question} ${def ? "(Y/n) " : "(y/N) "}`, (answer) => {
|
|
27
|
-
rl.close();
|
|
28
|
-
const a = answer.trim().toLowerCase();
|
|
29
|
-
resolve(a === "" ? def : a === "y" || a === "yes");
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
async function offerExtension(choice) {
|
|
34
|
-
if (!extensionBundled() || !hasCodeCli()) return null;
|
|
35
|
-
const yes = choice ?? await promptYesNo("Install the Apex .alpine VS Code extension (syntax highlighting)?");
|
|
36
|
-
if (!yes) return null;
|
|
37
|
-
return installExtension() ? "VS Code extension installed" : "VS Code extension install failed";
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export {
|
|
41
|
-
offerExtension
|
|
42
|
-
};
|