create-ramonyx-app 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/dist/cli.js +67 -7
- package/package.json +2 -1
- package/template/README.md +17 -8
- package/template/package.json +9 -9
- package/templates/api-auth/README.md +33 -0
- package/templates/api-auth/package.json +33 -0
- package/templates/api-auth/src/lib/auth.server.ts +81 -0
- package/templates/api-auth/src/routes/api/auth/logout.ts +7 -0
- package/templates/api-auth/src/routes/api/me.ts +12 -0
- package/templates/api-auth/src/routes/middleware.ts +6 -0
- package/templates/api-auth/src/routes/page.tsx +92 -0
- package/templates/cloudflare/README.md +43 -0
- package/templates/cloudflare/package.json +35 -0
- package/templates/cloudflare/src/routes/api/storage.ts +97 -0
- package/templates/cloudflare/src/routes/page.tsx +48 -0
- package/templates/cloudflare/wrangler.toml +15 -0
- package/templates/docs-blog/README.md +21 -0
- package/templates/docs-blog/package.json +34 -0
- package/templates/docs-blog/src/data/posts.ts +45 -0
- package/templates/docs-blog/src/routes/blog/[slug]/page.tsx +41 -0
- package/templates/docs-blog/src/routes/page.tsx +47 -0
package/README.md
CHANGED
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
Scaffolder aplikasi Ramonyx — SSR React + hydration, file routing `src/routes`.
|
|
4
4
|
|
|
5
|
-
> **Alpha (2.0.
|
|
5
|
+
> **Alpha (2.0.2)** — butuh **Node.js ≥ 24**.
|
|
6
6
|
|
|
7
7
|
## Pemakaian
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
|
|
11
|
-
# atau
|
|
12
|
-
pnpm
|
|
10
|
+
npm create ramonyx-app@latest aplikasi-saya
|
|
11
|
+
# atau:
|
|
12
|
+
pnpm create ramonyx-app@latest aplikasi-saya
|
|
13
|
+
yarn create ramonyx-app aplikasi-saya
|
|
14
|
+
bun create ramonyx-app aplikasi-saya
|
|
13
15
|
```
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
Pilih template dengan `--template minimal|api-auth|cloudflare|docs-blog`.
|
|
18
|
+
Lanjutkan dengan manager yang sama: `npm install && npm run dev`,
|
|
19
|
+
`pnpm install && pnpm dev`, `yarn && yarn dev`, atau `bun install && bun run dev`.
|
|
16
20
|
|
|
17
21
|
Repo: https://github.com/RamonyxJS/ramonyx · Lisensi: MIT
|
package/dist/cli.js
CHANGED
|
@@ -6,18 +6,33 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
var packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
8
|
var templateDir = join(packageRoot, "template");
|
|
9
|
+
var templatesDir = join(packageRoot, "templates");
|
|
9
10
|
var agentTemplateDir = join(packageRoot, "template-agent");
|
|
11
|
+
var TEMPLATES = {
|
|
12
|
+
minimal: "Welcome screen: SSR + hydration + API + form action",
|
|
13
|
+
"api-auth": "Session auth: login/logout, protected API, server action",
|
|
14
|
+
cloudflare: "Cloudflare Worker: R2 + KV binding example and wrangler config",
|
|
15
|
+
"docs-blog": "Static content: blog index + dynamic posts + SSG export"
|
|
16
|
+
};
|
|
17
|
+
var TEMPLATE_IDS = Object.keys(TEMPLATES);
|
|
10
18
|
var USAGE = `create-ramonyx-app \u2014 buat aplikasi Ramonyx baru
|
|
11
19
|
|
|
12
20
|
Penggunaan:
|
|
13
|
-
|
|
14
|
-
pnpm
|
|
15
|
-
|
|
21
|
+
npm create ramonyx-app@latest <dir> # registry
|
|
22
|
+
pnpm create ramonyx-app@latest <dir> # registry
|
|
23
|
+
yarn create ramonyx-app <dir> # registry
|
|
24
|
+
bun create ramonyx-app <dir> # registry
|
|
25
|
+
pnpm create:app <dir> # di dalam repo (workspace)
|
|
16
26
|
|
|
17
27
|
Opsi:
|
|
18
28
|
--force, -f Timpa berkas template walau direktori tujuan tidak kosong
|
|
19
29
|
--with-agent-docs Sertakan AGENTS.md + skill agent opt-in (tidak default)
|
|
30
|
+
--template <id> Template: minimal (default), api-auth, cloudflare, docs-blog
|
|
31
|
+
--list-templates Tampilkan template yang tersedia
|
|
20
32
|
--help, -h Tampilkan bantuan ini
|
|
33
|
+
|
|
34
|
+
Catatan pnpm: bila scaffold masih 1.x, pakai
|
|
35
|
+
pnpm create ramonyx-app@2.0.1 <dir>
|
|
21
36
|
`;
|
|
22
37
|
function usage(exitCode) {
|
|
23
38
|
const write = exitCode === 0 ? console.log : console.error;
|
|
@@ -27,12 +42,42 @@ function usage(exitCode) {
|
|
|
27
42
|
var args = process.argv.slice(2);
|
|
28
43
|
var force = false;
|
|
29
44
|
var withAgentDocs = false;
|
|
45
|
+
var template = "minimal";
|
|
30
46
|
var target;
|
|
31
|
-
for (
|
|
47
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
48
|
+
const arg = args[index] ?? "";
|
|
49
|
+
const next = () => {
|
|
50
|
+
const value = args[index + 1];
|
|
51
|
+
if (value === void 0 || value.startsWith("-")) {
|
|
52
|
+
console.error(`error: ${arg} membutuhkan nilai`);
|
|
53
|
+
usage(1);
|
|
54
|
+
}
|
|
55
|
+
index += 1;
|
|
56
|
+
return value;
|
|
57
|
+
};
|
|
32
58
|
if (arg === "--force" || arg === "-f") {
|
|
33
59
|
force = true;
|
|
34
60
|
} else if (arg === "--with-agent-docs") {
|
|
35
61
|
withAgentDocs = true;
|
|
62
|
+
} else if (arg === "--list-templates") {
|
|
63
|
+
for (const id of TEMPLATE_IDS) console.log(`${id.padEnd(12)} ${TEMPLATES[id]}`);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
} else if (arg === "--template") {
|
|
66
|
+
const value = next();
|
|
67
|
+
if (!TEMPLATE_IDS.includes(value)) {
|
|
68
|
+
console.error(`error: template tidak dikenal: ${value}`);
|
|
69
|
+
console.error(`tersedia: ${TEMPLATE_IDS.join(", ")}`);
|
|
70
|
+
usage(1);
|
|
71
|
+
}
|
|
72
|
+
template = value;
|
|
73
|
+
} else if (arg.startsWith("--template=")) {
|
|
74
|
+
const value = arg.slice("--template=".length);
|
|
75
|
+
if (!TEMPLATE_IDS.includes(value)) {
|
|
76
|
+
console.error(`error: template tidak dikenal: ${value}`);
|
|
77
|
+
console.error(`tersedia: ${TEMPLATE_IDS.join(", ")}`);
|
|
78
|
+
usage(1);
|
|
79
|
+
}
|
|
80
|
+
template = value;
|
|
36
81
|
} else if (arg === "--help" || arg === "-h") {
|
|
37
82
|
usage(0);
|
|
38
83
|
} else if (arg.startsWith("-")) {
|
|
@@ -77,14 +122,29 @@ function copy(source, destination) {
|
|
|
77
122
|
writeFileSync(join(dirname(destination), name), text);
|
|
78
123
|
}
|
|
79
124
|
copy(templateDir, targetDir);
|
|
125
|
+
if (template !== "minimal") {
|
|
126
|
+
const overlay = join(templatesDir, template);
|
|
127
|
+
if (!existsSync(overlay)) {
|
|
128
|
+
console.error(`error: berkas template ${template} tidak ditemukan di ${overlay}`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
copy(overlay, targetDir);
|
|
132
|
+
}
|
|
80
133
|
if (withAgentDocs) {
|
|
81
134
|
copy(agentTemplateDir, targetDir);
|
|
82
135
|
}
|
|
83
|
-
console.log(`\u2713 App "${appName}" dibuat di ${targetDir}
|
|
136
|
+
console.log(`\u2713 App "${appName}" dibuat di ${targetDir} (template: ${template})
|
|
84
137
|
|
|
85
138
|
Langkah berikutnya:
|
|
86
139
|
|
|
87
140
|
cd ${target}
|
|
88
|
-
|
|
89
|
-
|
|
141
|
+
|
|
142
|
+
Pilih SATU package manager (jangan campur lockfile):
|
|
143
|
+
|
|
144
|
+
npm install && npm run build && npm start
|
|
145
|
+
pnpm install && pnpm build && pnpm start
|
|
146
|
+
yarn && yarn build && yarn start
|
|
147
|
+
bun install && bun run build && bun run start
|
|
148
|
+
|
|
149
|
+
Dev server: ganti "build && start" dengan "run dev" / "dev".
|
|
90
150
|
${withAgentDocs ? "\nAgent docs: AGENTS.md + .opencode/skills/ramonyx/SKILL.md sudah disertakan.\n" : ""}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ramonyx-app",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Scaffolder aplikasi Ramonyx (SSR React + hydration, file routing src/routes).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
12
|
"template",
|
|
13
|
+
"templates",
|
|
13
14
|
"template-agent",
|
|
14
15
|
"README.md",
|
|
15
16
|
"LICENSE"
|
package/template/README.md
CHANGED
|
@@ -7,16 +7,25 @@ Aplikasi [Ramonyx](https://github.com/ramonyx/ramonyx) — framework full-stack
|
|
|
7
7
|
## Menjalankan
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
npm install
|
|
11
|
+
npm run dev # Vite + HMR + SSR (http://localhost:3000, satu port)
|
|
12
|
+
npm run build # bundel klien (wajib sebelum start produksi)
|
|
13
|
+
npm start # http://127.0.0.1:3000 (NODE_ENV=production otomatis)
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
Semua package manager yang diuji bisa dipakai:
|
|
17
|
+
|
|
18
|
+
| Manager | Install | Dev |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| npm | `npm install` | `npm run dev` |
|
|
21
|
+
| pnpm | `pnpm install` | `pnpm dev` |
|
|
22
|
+
| Yarn | `yarn` | `yarn dev` |
|
|
23
|
+
| Bun | `bun install` | `bun run dev` |
|
|
24
|
+
|
|
25
|
+
Perintah memakai CLI **`ramonyx`** (`@ramonyx/devtools`) yang terpasang **lokal** di
|
|
26
|
+
`node_modules/.bin`. Untuk memanggil CLI di luar package script, gunakan runner package manager:
|
|
27
|
+
`npx ramonyx …`, `pnpm exec ramonyx …`, atau `bunx ramonyx …`. `ramonyx …` tanpa runner hanya
|
|
28
|
+
tersedia setelah instalasi global.
|
|
20
29
|
|
|
21
30
|
## Struktur
|
|
22
31
|
|
package/template/package.json
CHANGED
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
"dev": "ramonyx dev"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@ramonyx/adapter-runtime": "^2.0.
|
|
15
|
-
"@ramonyx/bundler": "^2.0.
|
|
16
|
-
"@ramonyx/compiler": "^2.0.
|
|
17
|
-
"@ramonyx/devtools": "^2.0.
|
|
18
|
-
"@ramonyx/react": "^2.0.
|
|
19
|
-
"@ramonyx/security": "^2.0.
|
|
20
|
-
"@ramonyx/server": "^2.0.
|
|
21
|
-
"@ramonyx/ssr": "^2.0.
|
|
22
|
-
"ramonyx": "^2.0.
|
|
14
|
+
"@ramonyx/adapter-runtime": "^2.0.2",
|
|
15
|
+
"@ramonyx/bundler": "^2.0.2",
|
|
16
|
+
"@ramonyx/compiler": "^2.0.2",
|
|
17
|
+
"@ramonyx/devtools": "^2.0.2",
|
|
18
|
+
"@ramonyx/react": "^2.0.2",
|
|
19
|
+
"@ramonyx/security": "^2.0.2",
|
|
20
|
+
"@ramonyx/server": "^2.0.2",
|
|
21
|
+
"@ramonyx/ssr": "^2.0.2",
|
|
22
|
+
"ramonyx": "^2.0.2",
|
|
23
23
|
"react": "19.3.1",
|
|
24
24
|
"react-dom": "19.3.1"
|
|
25
25
|
},
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# __APP_NAME__ — API auth starter
|
|
2
|
+
|
|
3
|
+
A production-shaped starting point for session auth:
|
|
4
|
+
|
|
5
|
+
- `POST /` — login server action (progressive enhancement; no JS required)
|
|
6
|
+
- `GET /api/me` — current user or `401`
|
|
7
|
+
- `POST /api/auth/logout` — revoke the cookie
|
|
8
|
+
- `src/lib/auth.server.ts` — server-side session store, password verification, demo user store
|
|
9
|
+
- `src/routes/middleware.ts` — security headers, CSP nonce, and CSRF (`_csrf` / `x-csrf-token`)
|
|
10
|
+
- CSRF cookie is not `Secure` by default so HTTP dev works; enable `csrfProtection({ secure: true })`
|
|
11
|
+
in production or terminate TLS with a Secure-only policy.
|
|
12
|
+
|
|
13
|
+
## Run
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm install
|
|
17
|
+
pnpm dev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Demo credentials: `demo` / `demo-password-123`.
|
|
21
|
+
|
|
22
|
+
## Before production
|
|
23
|
+
|
|
24
|
+
1. Set a random `SESSION_SECRET` (32+ characters) in the environment or binding.
|
|
25
|
+
2. Replace `createMemoryUserStore()` and `createMemorySessionStore()` with database/KV adapters;
|
|
26
|
+
memory loses users and sessions on restart.
|
|
27
|
+
3. Add login rate limiting and a production auth provider (Better Auth is documented in the
|
|
28
|
+
integration recipes). CSRF is enabled; JSON clients must send the cookie plus `x-csrf-token`.
|
|
29
|
+
4. Serve over HTTPS so the session cookie's `Secure` attribute takes effect.
|
|
30
|
+
|
|
31
|
+
The template uses a server-side session store so logout revokes the server record (not just the
|
|
32
|
+
cookie). It deliberately keeps auth code in `src/lib/auth.server.ts`; never import it from a client
|
|
33
|
+
component.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "ramonyx build",
|
|
10
|
+
"start": "ramonyx start",
|
|
11
|
+
"dev": "ramonyx dev"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@ramonyx/adapter-runtime": "^2.0.2",
|
|
15
|
+
"@ramonyx/bundler": "^2.0.2",
|
|
16
|
+
"@ramonyx/compiler": "^2.0.2",
|
|
17
|
+
"@ramonyx/devtools": "^2.0.2",
|
|
18
|
+
"@ramonyx/react": "^2.0.2",
|
|
19
|
+
"@ramonyx/security": "^2.0.2",
|
|
20
|
+
"@ramonyx/server": "^2.0.2",
|
|
21
|
+
"@ramonyx/ssr": "^2.0.2",
|
|
22
|
+
"ramonyx": "^2.0.2",
|
|
23
|
+
"react": "19.3.1",
|
|
24
|
+
"react-dom": "19.3.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/react": "19.3.0",
|
|
28
|
+
"@types/react-dom": "19.3.0"
|
|
29
|
+
},
|
|
30
|
+
"imports": {
|
|
31
|
+
"#routes": "./.ramonyx/routes.ts"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Server-only auth demo. Replace the memory stores with a database/KV adapter before production.
|
|
2
|
+
import {
|
|
3
|
+
type AuthUser,
|
|
4
|
+
createMemorySessionStore,
|
|
5
|
+
createMemoryUserStore,
|
|
6
|
+
createSession,
|
|
7
|
+
hashPassword,
|
|
8
|
+
verifyPassword,
|
|
9
|
+
} from "@ramonyx/security";
|
|
10
|
+
import type { EnvReader } from "ramonyx";
|
|
11
|
+
|
|
12
|
+
const users = createMemoryUserStore();
|
|
13
|
+
// Server-side sessions make logout/revocation real. Replace with Redis/KV/DB in production.
|
|
14
|
+
const sessions = createMemorySessionStore();
|
|
15
|
+
const DEMO_USERNAME = "demo";
|
|
16
|
+
const DEMO_PASSWORD = "demo-password-123";
|
|
17
|
+
const DEV_SECRET = "dev-only-session-secret-change-me-32-chars";
|
|
18
|
+
|
|
19
|
+
let seeded: Promise<void> | undefined;
|
|
20
|
+
|
|
21
|
+
function ensureDemoUser(): Promise<void> {
|
|
22
|
+
seeded ??= (async () => {
|
|
23
|
+
if ((await users.getByUsername(DEMO_USERNAME)) !== null) return;
|
|
24
|
+
await users.create({
|
|
25
|
+
username: DEMO_USERNAME,
|
|
26
|
+
passwordHash: await hashPassword(DEMO_PASSWORD),
|
|
27
|
+
roles: ["user"],
|
|
28
|
+
});
|
|
29
|
+
})();
|
|
30
|
+
return seeded;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sessionSecret(env: EnvReader): string {
|
|
34
|
+
const secret = env.get("SESSION_SECRET");
|
|
35
|
+
if (secret !== undefined && secret.length >= 32) return secret;
|
|
36
|
+
if (env.get("NODE_ENV") === "production") {
|
|
37
|
+
throw new Error("SESSION_SECRET wajib diisi (minimal 32 karakter) di production");
|
|
38
|
+
}
|
|
39
|
+
return DEV_SECRET;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sessionFor(env: EnvReader) {
|
|
43
|
+
return createSession({
|
|
44
|
+
secret: sessionSecret(env),
|
|
45
|
+
store: sessions,
|
|
46
|
+
cookie: { secure: env.get("NODE_ENV") === "production" },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function currentUser(env: EnvReader, request: Request): Promise<AuthUser | null> {
|
|
51
|
+
const data = await sessionFor(env).read(request);
|
|
52
|
+
const userId = typeof data?.userId === "string" ? data.userId : null;
|
|
53
|
+
return userId === null ? null : users.getById(userId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type LoginResult =
|
|
57
|
+
| { readonly ok: true; readonly cookie: string; readonly user: AuthUser }
|
|
58
|
+
| { readonly ok: false; readonly error: string };
|
|
59
|
+
|
|
60
|
+
export async function login(env: EnvReader, formData: FormData): Promise<LoginResult> {
|
|
61
|
+
await ensureDemoUser();
|
|
62
|
+
const username = String(formData.get("username") ?? "").trim();
|
|
63
|
+
const password = String(formData.get("password") ?? "");
|
|
64
|
+
const user = await users.getByUsername(username);
|
|
65
|
+
if (user === null || user.passwordHash === undefined) {
|
|
66
|
+
return { ok: false, error: "Invalid username or password." };
|
|
67
|
+
}
|
|
68
|
+
const valid = password.length > 0 && (await verifyPassword(password, user.passwordHash));
|
|
69
|
+
if (!valid) {
|
|
70
|
+
return { ok: false, error: "Invalid username or password." };
|
|
71
|
+
}
|
|
72
|
+
const cookie = await sessionFor(env).commit({
|
|
73
|
+
userId: user.id,
|
|
74
|
+
username: user.username,
|
|
75
|
+
});
|
|
76
|
+
return { ok: true, cookie, user };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function logoutCookie(env: EnvReader, request: Request): Promise<string> {
|
|
80
|
+
return sessionFor(env).revoke(request);
|
|
81
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { route } from "ramonyx";
|
|
2
|
+
import { currentUser } from "../../lib/auth.server.ts";
|
|
3
|
+
|
|
4
|
+
export const GET = route({
|
|
5
|
+
handler: async ({ env, request, json }) => {
|
|
6
|
+
const user = await currentUser(env, request);
|
|
7
|
+
if (user === null) {
|
|
8
|
+
return json({ error: "Unauthorized" }, 401);
|
|
9
|
+
}
|
|
10
|
+
return json({ user: { id: user.id, username: user.username } });
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { cspNonce, csrfProtection, securityHeaders } from "@ramonyx/security";
|
|
2
|
+
import type { Middleware } from "ramonyx";
|
|
3
|
+
|
|
4
|
+
// Root middleware applies to every page/API route (ADR-0061).
|
|
5
|
+
// CSRF is double-submit: GET issues a cookie, forms echo `_csrf`, JSON clients send x-csrf-token.
|
|
6
|
+
export default [securityHeaders(), cspNonce(), csrfProtection()] satisfies Middleware[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { PageProps } from "@ramonyx/react";
|
|
2
|
+
import { CSRF_REQUEST_HEADER } from "@ramonyx/security";
|
|
3
|
+
import type { PageActionContext, PageLoaderContext } from "ramonyx";
|
|
4
|
+
import { currentUser, login } from "../lib/auth.server.ts";
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
title: "__APP_NAME__ — account",
|
|
8
|
+
description: "Session auth starter with a server action, protected API, and logout.",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function loader({ env, request }: PageLoaderContext) {
|
|
12
|
+
const user = await currentUser(env, request);
|
|
13
|
+
return {
|
|
14
|
+
user: user === null ? null : { id: user.id, username: user.username },
|
|
15
|
+
csrf: request.headers.get(CSRF_REQUEST_HEADER) ?? "",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function action({ env, formData }: PageActionContext) {
|
|
20
|
+
const result = await login(env, formData);
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
return { errors: { form: result.error } };
|
|
23
|
+
}
|
|
24
|
+
return { redirect: "/", headers: { "set-cookie": result.cookie } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function Account({
|
|
28
|
+
data,
|
|
29
|
+
action,
|
|
30
|
+
}: PageProps<{ user: { id: string; username: string } | null }>) {
|
|
31
|
+
return (
|
|
32
|
+
<main className="rw-main">
|
|
33
|
+
<section className="rw-hero">
|
|
34
|
+
<div className="rw-hero-copy">
|
|
35
|
+
<p className="rw-kicker">API auth starter</p>
|
|
36
|
+
<h1>
|
|
37
|
+
Sessions that survive a <span>server restart boundary.</span>
|
|
38
|
+
</h1>
|
|
39
|
+
<p className="rw-lede">
|
|
40
|
+
Signed, HTTP-only session cookies, a password-verified server action, a protected API,
|
|
41
|
+
and logout. The demo user store is in-memory; replace it before deploying.
|
|
42
|
+
</p>
|
|
43
|
+
|
|
44
|
+
{data.user === null ? (
|
|
45
|
+
<form method="post" className="rw-form">
|
|
46
|
+
<input type="hidden" name="_csrf" value={data.csrf} />
|
|
47
|
+
<label htmlFor="username">Username</label>
|
|
48
|
+
<input id="username" name="username" autoComplete="username" defaultValue="demo" />
|
|
49
|
+
<label htmlFor="password">Password</label>
|
|
50
|
+
<input
|
|
51
|
+
id="password"
|
|
52
|
+
name="password"
|
|
53
|
+
type="password"
|
|
54
|
+
autoComplete="current-password"
|
|
55
|
+
defaultValue="demo-password-123"
|
|
56
|
+
/>
|
|
57
|
+
<button type="submit">Sign in</button>
|
|
58
|
+
</form>
|
|
59
|
+
) : (
|
|
60
|
+
<div className="rw-actions">
|
|
61
|
+
<p className="rw-success" role="status">
|
|
62
|
+
Signed in as <strong>{data.user.username}</strong>
|
|
63
|
+
</p>
|
|
64
|
+
<form method="post" action="/api/auth/logout">
|
|
65
|
+
<input type="hidden" name="_csrf" value={data.csrf} />
|
|
66
|
+
<button type="submit" className="rw-quiet">
|
|
67
|
+
Sign out
|
|
68
|
+
</button>
|
|
69
|
+
</form>
|
|
70
|
+
<a className="rw-button" href="/api/me">
|
|
71
|
+
Open /api/me
|
|
72
|
+
</a>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{action?.errors?.form !== undefined ? (
|
|
77
|
+
<p className="rw-error" role="alert">
|
|
78
|
+
{action.errors.form}
|
|
79
|
+
</p>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<aside className="rw-status">
|
|
84
|
+
<p className="rw-label">DEMO CREDENTIALS</p>
|
|
85
|
+
<p className="rw-url">demo</p>
|
|
86
|
+
<p className="rw-status-sub">demo-password-123</p>
|
|
87
|
+
<p className="rw-status-sub">Set SESSION_SECRET before production.</p>
|
|
88
|
+
</aside>
|
|
89
|
+
</section>
|
|
90
|
+
</main>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# __APP_NAME__ — Cloudflare R2 + KV starter
|
|
2
|
+
|
|
3
|
+
Deploy the same Ramonyx route tree as a Cloudflare Worker with real R2/KV bindings.
|
|
4
|
+
|
|
5
|
+
- `GET /api/storage?key=demo` — read KV + R2
|
|
6
|
+
- `POST /api/storage` — `{ "key": "demo", "value": "hello" }`
|
|
7
|
+
- `GET /` — HTML overview
|
|
8
|
+
|
|
9
|
+
## Configure
|
|
10
|
+
|
|
11
|
+
Edit `wrangler.toml` and replace the placeholder KV namespace ID. Create the resources first:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx wrangler kv namespace create KV_STORE
|
|
15
|
+
npx wrangler r2 bucket create __APP_NAME__-files
|
|
16
|
+
npx wrangler secret put STORAGE_TOKEN # random token, 16+ chars
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install
|
|
23
|
+
npm run dev # Node: binding routes return 503
|
|
24
|
+
npm run build:edge # dist/edge/index.js
|
|
25
|
+
npm run deploy # build + wrangler deploy
|
|
26
|
+
|
|
27
|
+
curl -H "Authorization: Bearer $STORAGE_TOKEN" \
|
|
28
|
+
https://your-worker.example/api/storage?key=demo
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`npm` is shown for brevity. `pnpm install && pnpm dev`, `yarn && yarn dev`, and
|
|
32
|
+
`bun install && bun run dev` work too; use the same manager for scripts.
|
|
33
|
+
|
|
34
|
+
R2/KV are Cloudflare bindings, not Node packages. Use `wrangler dev`/Miniflare or the deployed
|
|
35
|
+
Worker to exercise them; plain `ramonyx dev` has no bindings. The storage API requires
|
|
36
|
+
`Authorization: Bearer <STORAGE_TOKEN>`; without the secret it returns 503. For local integration
|
|
37
|
+
tests, the repo uses the edge smoke with Miniflare.
|
|
38
|
+
|
|
39
|
+
## Notes
|
|
40
|
+
|
|
41
|
+
- Keep the R2 bucket private unless you add a public custom domain.
|
|
42
|
+
- Use random object keys; do not trust a client-provided filename.
|
|
43
|
+
- The recipe `docs/recipes/cloudflare-r2-kv` covers upload/download streaming and limits.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "ramonyx build",
|
|
10
|
+
"start": "ramonyx start",
|
|
11
|
+
"dev": "ramonyx dev",
|
|
12
|
+
"build:edge": "ramonyx build --target edge",
|
|
13
|
+
"deploy": "ramonyx build --target edge && npx wrangler deploy"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@ramonyx/adapter-runtime": "^2.0.2",
|
|
17
|
+
"@ramonyx/bundler": "^2.0.2",
|
|
18
|
+
"@ramonyx/compiler": "^2.0.2",
|
|
19
|
+
"@ramonyx/devtools": "^2.0.2",
|
|
20
|
+
"@ramonyx/react": "^2.0.2",
|
|
21
|
+
"@ramonyx/security": "^2.0.2",
|
|
22
|
+
"@ramonyx/server": "^2.0.2",
|
|
23
|
+
"@ramonyx/ssr": "^2.0.2",
|
|
24
|
+
"ramonyx": "^2.0.2",
|
|
25
|
+
"react": "19.3.1",
|
|
26
|
+
"react-dom": "19.3.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/react": "19.3.0",
|
|
30
|
+
"@types/react-dom": "19.3.0"
|
|
31
|
+
},
|
|
32
|
+
"imports": {
|
|
33
|
+
"#routes": "./.ramonyx/routes.ts"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type EnvReader, HttpError, route } from "ramonyx";
|
|
2
|
+
|
|
3
|
+
// Cloudflare KV + R2 bindings (ADR-0068). Node dev has no bindings → 503.
|
|
4
|
+
interface KVNamespaceLike {
|
|
5
|
+
get(key: string): Promise<string | null>;
|
|
6
|
+
put(key: string, value: string, options?: { readonly expirationTtl?: number }): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface R2ObjectLike {
|
|
10
|
+
readonly size?: number;
|
|
11
|
+
readonly httpEtag?: string;
|
|
12
|
+
text(): Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface R2BucketLike {
|
|
16
|
+
get(key: string): Promise<R2ObjectLike | null>;
|
|
17
|
+
put(
|
|
18
|
+
key: string,
|
|
19
|
+
value: string,
|
|
20
|
+
options?: { readonly httpMetadata?: { readonly contentType?: string } },
|
|
21
|
+
): Promise<unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sameSecret(a: string, b: string): boolean {
|
|
25
|
+
let diff = a.length ^ b.length;
|
|
26
|
+
const length = Math.max(a.length, b.length);
|
|
27
|
+
for (let index = 0; index < length; index += 1) {
|
|
28
|
+
diff |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
|
|
29
|
+
}
|
|
30
|
+
return diff === 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function authorize(env: EnvReader, request: Request): void {
|
|
34
|
+
const expected = env.get("STORAGE_TOKEN");
|
|
35
|
+
if (expected === undefined || expected.length < 16) {
|
|
36
|
+
throw new HttpError(503, "STORAGE_TOKEN belum dikonfigurasi");
|
|
37
|
+
}
|
|
38
|
+
const header = request.headers.get("authorization") ?? "";
|
|
39
|
+
const supplied = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
40
|
+
if (!sameSecret(expected, supplied)) {
|
|
41
|
+
throw new HttpError(401, "Unauthorized");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function bindings(env: EnvReader): { kv: KVNamespaceLike; bucket: R2BucketLike } {
|
|
46
|
+
const kv = env.getRaw?.("KV_STORE") as KVNamespaceLike | undefined;
|
|
47
|
+
const bucket = env.getRaw?.("R2_BUCKET") as R2BucketLike | undefined;
|
|
48
|
+
if (kv === undefined || bucket === undefined) {
|
|
49
|
+
throw new HttpError(503, "Binding KV_STORE/R2_BUCKET tidak tersedia");
|
|
50
|
+
}
|
|
51
|
+
return { kv, bucket };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function keyFrom(value: unknown): string {
|
|
55
|
+
const key = String(value ?? "demo").trim();
|
|
56
|
+
if (key.length === 0 || key.length > 100) {
|
|
57
|
+
throw new HttpError(400, "key wajib 1–100 karakter");
|
|
58
|
+
}
|
|
59
|
+
return key;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const GET = route({
|
|
63
|
+
handler: async ({ env, request, json }) => {
|
|
64
|
+
authorize(env, request);
|
|
65
|
+
const key = keyFrom(new URL(request.url).searchParams.get("key"));
|
|
66
|
+
const { kv, bucket } = bindings(env);
|
|
67
|
+
const [kvValue, object] = await Promise.all([kv.get(key), bucket.get(key)]);
|
|
68
|
+
if (kvValue === null && object === null) {
|
|
69
|
+
return json({ error: "Not Found" }, 404);
|
|
70
|
+
}
|
|
71
|
+
return json({
|
|
72
|
+
key,
|
|
73
|
+
kv: kvValue,
|
|
74
|
+
r2: object === null ? null : await object.text(),
|
|
75
|
+
size: object?.size ?? null,
|
|
76
|
+
etag: object?.httpEtag ?? null,
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const POST = route({
|
|
82
|
+
handler: async ({ env, request, json }) => {
|
|
83
|
+
authorize(env, request);
|
|
84
|
+
const body = (await request.json()) as { key?: unknown; value?: unknown };
|
|
85
|
+
const key = keyFrom(body.key);
|
|
86
|
+
const value = String(body.value ?? "");
|
|
87
|
+
if (value.length > 1024) {
|
|
88
|
+
return json({ error: "value maksimal 1024 karakter" }, 400);
|
|
89
|
+
}
|
|
90
|
+
const { kv, bucket } = bindings(env);
|
|
91
|
+
await Promise.all([
|
|
92
|
+
kv.put(key, value, { expirationTtl: 3600 }),
|
|
93
|
+
bucket.put(key, value, { httpMetadata: { contentType: "text/plain; charset=utf-8" } }),
|
|
94
|
+
]);
|
|
95
|
+
return json({ ok: true, key }, 201);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const metadata = {
|
|
2
|
+
title: "__APP_NAME__ — Cloudflare storage",
|
|
3
|
+
description: "Ramonyx on Cloudflare Workers with R2 object storage and KV metadata.",
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export default function Home() {
|
|
7
|
+
return (
|
|
8
|
+
<main className="rw-main">
|
|
9
|
+
<section className="rw-hero">
|
|
10
|
+
<div className="rw-hero-copy">
|
|
11
|
+
<p className="rw-kicker">Cloudflare-native</p>
|
|
12
|
+
<h1>
|
|
13
|
+
Files in R2.
|
|
14
|
+
<br />
|
|
15
|
+
<span>Metadata in KV.</span>
|
|
16
|
+
</h1>
|
|
17
|
+
<p className="rw-lede">
|
|
18
|
+
This template ships a Worker artifact and real binding routes. The API reads and writes
|
|
19
|
+
both stores; the browser page is only a map of the surface.
|
|
20
|
+
</p>
|
|
21
|
+
<div className="rw-actions">
|
|
22
|
+
<a className="rw-button" href="/api/storage?key=demo">
|
|
23
|
+
Try the storage API
|
|
24
|
+
</a>
|
|
25
|
+
<a
|
|
26
|
+
className="rw-quiet"
|
|
27
|
+
href="https://ramonyx.devmoon.net/docs/recipes/cloudflare-r2-kv"
|
|
28
|
+
>
|
|
29
|
+
Read the R2 + KV recipe ↗
|
|
30
|
+
</a>
|
|
31
|
+
</div>
|
|
32
|
+
<pre className="rw-tree">{`GET /api/storage?key=demo
|
|
33
|
+
POST /api/storage
|
|
34
|
+
{ "key": "demo", "value": "hello" }
|
|
35
|
+
|
|
36
|
+
pnpm build:edge
|
|
37
|
+
pnpm deploy`}</pre>
|
|
38
|
+
</div>
|
|
39
|
+
<aside className="rw-status">
|
|
40
|
+
<p className="rw-label">BINDINGS</p>
|
|
41
|
+
<p className="rw-url">KV_STORE</p>
|
|
42
|
+
<p className="rw-status-sub">R2_BUCKET</p>
|
|
43
|
+
<p className="rw-status-sub">Configure in wrangler.toml</p>
|
|
44
|
+
</aside>
|
|
45
|
+
</section>
|
|
46
|
+
</main>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name = "__APP_NAME__"
|
|
2
|
+
main = "dist/edge/index.js"
|
|
3
|
+
compatibility_date = "2026-10-18"
|
|
4
|
+
|
|
5
|
+
# Static client assets produced by `ramonyx build --target edge`.
|
|
6
|
+
[assets]
|
|
7
|
+
directory = "dist/client"
|
|
8
|
+
|
|
9
|
+
[[kv_namespaces]]
|
|
10
|
+
binding = "KV_STORE"
|
|
11
|
+
id = "replace-with-your-kv-namespace-id"
|
|
12
|
+
|
|
13
|
+
[[r2_buckets]]
|
|
14
|
+
binding = "R2_BUCKET"
|
|
15
|
+
bucket_name = "__APP_NAME__-files"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# __APP_NAME__ — docs/blog starter
|
|
2
|
+
|
|
3
|
+
A content-first Ramonyx app with a static export:
|
|
4
|
+
|
|
5
|
+
- `/` — post index
|
|
6
|
+
- `/blog/[slug]` — prerendered posts via `getStaticPaths()`
|
|
7
|
+
- `pnpm build:static` — HTML into `dist/static`, ready for Pages, Workers Assets, or any static host
|
|
8
|
+
|
|
9
|
+
## Run
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm install
|
|
13
|
+
pnpm dev
|
|
14
|
+
pnpm build:static
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Add posts in `src/data/posts.ts`; the dynamic route and static path list update together. Replace
|
|
18
|
+
the in-memory post list with markdown/files or a CMS when you need real content workflow.
|
|
19
|
+
|
|
20
|
+
The docs site (`apps/docs` in the repository) is a larger example of the same SSR + static export
|
|
21
|
+
model.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "ramonyx build",
|
|
10
|
+
"start": "ramonyx start",
|
|
11
|
+
"dev": "ramonyx dev",
|
|
12
|
+
"build:static": "ramonyx build --static"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@ramonyx/adapter-runtime": "^2.0.2",
|
|
16
|
+
"@ramonyx/bundler": "^2.0.2",
|
|
17
|
+
"@ramonyx/compiler": "^2.0.2",
|
|
18
|
+
"@ramonyx/devtools": "^2.0.2",
|
|
19
|
+
"@ramonyx/react": "^2.0.2",
|
|
20
|
+
"@ramonyx/security": "^2.0.2",
|
|
21
|
+
"@ramonyx/server": "^2.0.2",
|
|
22
|
+
"@ramonyx/ssr": "^2.0.2",
|
|
23
|
+
"ramonyx": "^2.0.2",
|
|
24
|
+
"react": "19.3.1",
|
|
25
|
+
"react-dom": "19.3.1"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/react": "19.3.0",
|
|
29
|
+
"@types/react-dom": "19.3.0"
|
|
30
|
+
},
|
|
31
|
+
"imports": {
|
|
32
|
+
"#routes": "./.ramonyx/routes.ts"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface Post {
|
|
2
|
+
readonly slug: string;
|
|
3
|
+
readonly title: string;
|
|
4
|
+
readonly summary: string;
|
|
5
|
+
readonly body: readonly string[];
|
|
6
|
+
readonly published: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const posts: readonly Post[] = [
|
|
10
|
+
{
|
|
11
|
+
slug: "hello-ramonyx",
|
|
12
|
+
title: "A content site with no client runtime",
|
|
13
|
+
summary: "Every page is server-rendered HTML that can also be exported statically.",
|
|
14
|
+
published: "2026-10-24",
|
|
15
|
+
body: [
|
|
16
|
+
"This starter renders a blog index and a dynamic post route on the server. No client-side data fetching is required for reading.",
|
|
17
|
+
"Run `pnpm build:static` to prerender every post into dist/static. The same route tree also runs with `pnpm build && pnpm start` for SSR.",
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
slug: "markdown-next",
|
|
22
|
+
title: "Replace the demo data with Markdown",
|
|
23
|
+
summary:
|
|
24
|
+
"The post list is deliberately small and typed so a file/markdown source can replace it.",
|
|
25
|
+
published: "2026-10-24",
|
|
26
|
+
body: [
|
|
27
|
+
"`src/data/posts.ts` is the only content source. Swap it for a filesystem loader or a CMS client and keep the page contract.",
|
|
28
|
+
"Keep slugs validated and escape rendered HTML; never interpolate untrusted Markdown output without sanitizing it.",
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
slug: "deploy-anywhere",
|
|
33
|
+
title: "Deploy to static hosting or a Node server",
|
|
34
|
+
summary: "The same pages can be exported, served by Ramonyx, or bundled for an edge target.",
|
|
35
|
+
published: "2026-10-24",
|
|
36
|
+
body: [
|
|
37
|
+
"Static export produces HTML and hashed assets, so it works on Pages, Workers Assets, or any object store with a CDN.",
|
|
38
|
+
"For dynamic features later, switch to the Node or edge target and add API routes without changing the blog URLs.",
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function findPost(slug: string): Post | null {
|
|
44
|
+
return posts.find((post) => post.slug === slug) ?? null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PageProps } from "@ramonyx/react";
|
|
2
|
+
import { HttpError, type PageLoaderContext } from "ramonyx";
|
|
3
|
+
import { findPost, type Post, posts } from "../../../data/posts.ts";
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
title: "__APP_NAME__ — post",
|
|
7
|
+
description: "A prerendered Ramonyx blog post.",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function getStaticPaths() {
|
|
11
|
+
return posts.map((post) => ({ slug: post.slug }));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loader({ params }: PageLoaderContext<{ slug: string }>) {
|
|
15
|
+
const post = findPost(params.slug);
|
|
16
|
+
if (post === null) {
|
|
17
|
+
throw new HttpError(404, "Post not found");
|
|
18
|
+
}
|
|
19
|
+
return { post };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function PostPage({ data }: PageProps<{ post: Post }>) {
|
|
23
|
+
return (
|
|
24
|
+
<main className="rw-main">
|
|
25
|
+
<article className="rw-section" style={{ maxWidth: "72ch" }}>
|
|
26
|
+
<p className="rw-label">{data.post.published}</p>
|
|
27
|
+
<h1>{data.post.title}</h1>
|
|
28
|
+
{data.post.body.map((paragraph) => (
|
|
29
|
+
<p key={paragraph} className="rw-lede">
|
|
30
|
+
{paragraph}
|
|
31
|
+
</p>
|
|
32
|
+
))}
|
|
33
|
+
<p>
|
|
34
|
+
<a className="rw-quiet" href="/">
|
|
35
|
+
← All notes
|
|
36
|
+
</a>
|
|
37
|
+
</p>
|
|
38
|
+
</article>
|
|
39
|
+
</main>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { posts } from "../data/posts.ts";
|
|
2
|
+
|
|
3
|
+
export const metadata = {
|
|
4
|
+
title: "__APP_NAME__ — notes",
|
|
5
|
+
description: "A static-first blog and docs starter built with Ramonyx.",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function Home() {
|
|
9
|
+
return (
|
|
10
|
+
<main className="rw-main">
|
|
11
|
+
<section className="rw-hero">
|
|
12
|
+
<div className="rw-hero-copy">
|
|
13
|
+
<p className="rw-kicker">Docs / blog starter</p>
|
|
14
|
+
<h1>
|
|
15
|
+
Write once.
|
|
16
|
+
<br />
|
|
17
|
+
<span>Render as HTML.</span>
|
|
18
|
+
</h1>
|
|
19
|
+
<p className="rw-lede">
|
|
20
|
+
Typed posts, a dynamic route, and a static export. The reading experience needs no
|
|
21
|
+
client JavaScript.
|
|
22
|
+
</p>
|
|
23
|
+
<div className="rw-actions">
|
|
24
|
+
<a className="rw-button" href="/blog/hello-ramonyx">
|
|
25
|
+
Read the first post
|
|
26
|
+
</a>
|
|
27
|
+
<a className="rw-quiet" href="https://ramonyx.devmoon.net/docs/deployment">
|
|
28
|
+
Deployment guide ↗
|
|
29
|
+
</a>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="rw-steps">
|
|
32
|
+
{posts.map((post, index) => (
|
|
33
|
+
<article className="rw-step" key={post.slug}>
|
|
34
|
+
<span className="rw-step-index">{String(index + 1).padStart(2, "0")}</span>
|
|
35
|
+
<h2>
|
|
36
|
+
<a href={`/blog/${post.slug}`}>{post.title}</a>
|
|
37
|
+
</h2>
|
|
38
|
+
<p>{post.summary}</p>
|
|
39
|
+
<code>{post.published}</code>
|
|
40
|
+
</article>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
</main>
|
|
46
|
+
);
|
|
47
|
+
}
|