Merge pull request 'feat/initial-build' (#1) from feat/initial-build into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Logto auth endpoints — the marketing site only performs <a href> navigations to these.
|
||||||
|
# No tokens, no cookies, no XHR — these are plain hyperlinks.
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL=https://auth.cameleer.io/sign-in
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL=https://auth.cameleer.io/sign-in?first_screen=register
|
||||||
|
PUBLIC_SALES_EMAIL=sales@cameleer.io
|
||||||
99
.gitea/workflows/ci.yml
Normal file
99
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# cameleer-website — CI (build + test + lint + Lighthouse)
|
||||||
|
#
|
||||||
|
# Runs automatically on every push and every PR against main. Does NOT deploy —
|
||||||
|
# see deploy.yml for that. This workflow exists so every commit gets the full
|
||||||
|
# quality gate before it can reach production.
|
||||||
|
#
|
||||||
|
# Runner: self-hosted arm64 Gitea runner (act_runner).
|
||||||
|
# Adjust `runs-on` labels if your runner is registered under different tags.
|
||||||
|
# Architecture note: arm64 build, amd64 deploy is fine — Astro's output is
|
||||||
|
# plain static HTML/CSS/JS with no arch-specific bits.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: ${{ vars.PUBLIC_AUTH_SIGNIN_URL }}
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: ${{ vars.PUBLIC_AUTH_SIGNUP_URL }}
|
||||||
|
PUBLIC_SALES_EMAIL: ${{ vars.PUBLIC_SALES_EMAIL }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
# Lighthouse CI needs a Chrome/Chromium binary at runtime. Google Chrome
|
||||||
|
# has no Linux/arm64 build, so install distro Chromium and export its
|
||||||
|
# path. Handles both `chromium` (Debian) and `chromium-browser` (older
|
||||||
|
# Ubuntu) package names, and works whether sudo is present or absent
|
||||||
|
# (e.g. runner running as root).
|
||||||
|
- name: Install Chromium for Lighthouse CI
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi
|
||||||
|
|
||||||
|
resolve_chromium() {
|
||||||
|
command -v chromium 2>/dev/null \
|
||||||
|
|| command -v chromium-browser 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
}
|
||||||
|
|
||||||
|
CHROME_BIN="$(resolve_chromium)"
|
||||||
|
if [ -z "$CHROME_BIN" ]; then
|
||||||
|
$SUDO apt-get update -qq
|
||||||
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
|
chromium chromium-driver \
|
||||||
|
|| $SUDO apt-get install -y --no-install-recommends \
|
||||||
|
chromium-browser chromium-chromedriver
|
||||||
|
CHROME_BIN="$(resolve_chromium)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CHROME_BIN" ]; then
|
||||||
|
echo "Failed to install a Chromium binary — Lighthouse CI cannot run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "CHROME_PATH=$CHROME_BIN" >> "$GITHUB_ENV"
|
||||||
|
"$CHROME_BIN" --version || true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build site
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Guard — no TBD markers may ship in built HTML
|
||||||
|
run: |
|
||||||
|
if grep -rl 'TBD:' dist 2>/dev/null | grep -E '\.(html|svg)$'; then
|
||||||
|
echo "Built output contains unfilled <TBD:...> markers."
|
||||||
|
echo "Fill in imprint.astro and privacy.astro operator fields before merging to main."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate HTML
|
||||||
|
run: npm run lint:html
|
||||||
|
|
||||||
|
- name: Check internal links
|
||||||
|
run: npm run lint:links
|
||||||
|
|
||||||
|
- name: Lighthouse CI
|
||||||
|
env:
|
||||||
|
CHROME_PATH: ${{ env.CHROME_PATH }}
|
||||||
|
run: npx lhci autorun
|
||||||
126
.gitea/workflows/deploy.yml
Normal file
126
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# cameleer-website — Deploy to Hetzner Webhosting L
|
||||||
|
#
|
||||||
|
# Runs ONLY on pushes to `main` and on manual dispatch from the Gitea UI.
|
||||||
|
# Does NOT run Lighthouse CI (that's in ci.yml — assume any commit that reached
|
||||||
|
# main already passed the full gate). Rebuilds fresh, runs the TBD guard, and
|
||||||
|
# rsyncs `dist/` to the origin over SSH with host-key pinning.
|
||||||
|
#
|
||||||
|
# Runner: self-hosted arm64 Gitea runner. Adjust `runs-on` if your runner's
|
||||||
|
# labels differ. Deploy target is Hetzner amd64 — arch mismatch is a non-issue
|
||||||
|
# because the bundle is static HTML/CSS/JS.
|
||||||
|
#
|
||||||
|
# Required secrets (repo settings → Actions → Secrets):
|
||||||
|
# SFTP_HOST, SFTP_USER, SFTP_PATH, SFTP_KEY, SFTP_KNOWN_HOSTS
|
||||||
|
# Required variables (repo settings → Actions → Variables):
|
||||||
|
# PUBLIC_AUTH_SIGNIN_URL, PUBLIC_AUTH_SIGNUP_URL, PUBLIC_SALES_EMAIL
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
name: deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: ${{ vars.PUBLIC_AUTH_SIGNIN_URL }}
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: ${{ vars.PUBLIC_AUTH_SIGNUP_URL }}
|
||||||
|
PUBLIC_SALES_EMAIL: ${{ vars.PUBLIC_SALES_EMAIL }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run unit tests (sanity check)
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build site
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Guard — no TBD markers may ship in built HTML
|
||||||
|
run: |
|
||||||
|
if grep -rl 'TBD:' dist 2>/dev/null | grep -E '\.(html|svg)$'; then
|
||||||
|
echo "Built output contains unfilled <TBD:...> markers."
|
||||||
|
echo "Fill in imprint.astro and privacy.astro operator fields before merging to main."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload dist artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download dist artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Configure SSH
|
||||||
|
env:
|
||||||
|
SFTP_KEY: ${{ secrets.SFTP_KEY }}
|
||||||
|
SFTP_KNOWN_HOSTS: ${{ secrets.SFTP_KNOWN_HOSTS }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
: "${SFTP_KEY:?SFTP_KEY secret must be set}"
|
||||||
|
: "${SFTP_KNOWN_HOSTS:?SFTP_KNOWN_HOSTS secret must be set}"
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SFTP_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
printf '%s\n' "$SFTP_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
|
chmod 644 ~/.ssh/known_hosts
|
||||||
|
# Ensure rsync + openssh are present even on a minimal runner image.
|
||||||
|
if ! command -v rsync >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
|
||||||
|
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi
|
||||||
|
$SUDO apt-get update -qq
|
||||||
|
$SUDO apt-get install -y --no-install-recommends rsync openssh-client
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy via rsync
|
||||||
|
env:
|
||||||
|
SFTP_USER: ${{ secrets.SFTP_USER }}
|
||||||
|
SFTP_HOST: ${{ secrets.SFTP_HOST }}
|
||||||
|
SFTP_PATH: ${{ secrets.SFTP_PATH }}
|
||||||
|
run: |
|
||||||
|
# Fail loudly if any secret is missing — otherwise rsync --delete
|
||||||
|
# could be directed at the SSH user's home root.
|
||||||
|
: "${SFTP_USER:?SFTP_USER secret must be set}"
|
||||||
|
: "${SFTP_HOST:?SFTP_HOST secret must be set}"
|
||||||
|
: "${SFTP_PATH:?SFTP_PATH secret must be set}"
|
||||||
|
rsync -avz --delete \
|
||||||
|
-e "ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes -o UserKnownHostsFile=~/.ssh/known_hosts" \
|
||||||
|
dist/ "$SFTP_USER@$SFTP_HOST:$SFTP_PATH/"
|
||||||
|
|
||||||
|
- name: Post-deploy smoke test
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
echo "Checking security headers on www.cameleer.io..."
|
||||||
|
HEADERS=$(curl -sI https://www.cameleer.io/ || echo "")
|
||||||
|
echo "$HEADERS" | grep -i '^strict-transport-security:' || { echo "HSTS missing"; exit 1; }
|
||||||
|
echo "$HEADERS" | grep -i '^content-security-policy:' || { echo "CSP missing"; exit 1; }
|
||||||
|
echo "$HEADERS" | grep -i '^x-frame-options:' || { echo "XFO missing"; exit 1; }
|
||||||
|
echo "All required headers present on the live origin."
|
||||||
8
.htmlvalidate.json
Normal file
8
.htmlvalidate.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["html-validate:recommended"],
|
||||||
|
"rules": {
|
||||||
|
"require-sri": "off",
|
||||||
|
"no-inline-style": "off",
|
||||||
|
"void-style": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
OPERATOR-CHECKLIST.md
Normal file
92
OPERATOR-CHECKLIST.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Operator Checklist — `cameleer-website`
|
||||||
|
|
||||||
|
One-time setup that lives outside code. Do these before the first `main` merge that ships live.
|
||||||
|
|
||||||
|
## 1. Hetzner Webhosting L
|
||||||
|
|
||||||
|
- [ ] Provision Webhosting L plan. Note the SSH hostname and SFTP path.
|
||||||
|
- [ ] In the Hetzner control panel, **enable SSH access** for the main user.
|
||||||
|
- [ ] Generate an ed25519 SSH key pair locally (once):
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -f ~/.ssh/cameleer-website-deploy -C "cameleer-website CI"
|
||||||
|
```
|
||||||
|
- [ ] Add the **public** key to `~/.ssh/authorized_keys` on the Hetzner account.
|
||||||
|
- [ ] Test SSH: `ssh -i ~/.ssh/cameleer-website-deploy user@hetzner-host "ls -la"`.
|
||||||
|
- [ ] Create a subdirectory for the site (typical path: `public_html/www.cameleer.io/`).
|
||||||
|
- [ ] Grab the SSH host key for pinning:
|
||||||
|
```bash
|
||||||
|
ssh-keyscan -t ed25519 hetzner-host > hetzner-known-hosts.txt
|
||||||
|
```
|
||||||
|
- [ ] Install Let's Encrypt (or use Hetzner's built-in) for the origin hostname. Cloudflare Full (strict) requires a valid origin cert.
|
||||||
|
|
||||||
|
## 2. Cloudflare (zone: cameleer.io)
|
||||||
|
|
||||||
|
### DNS
|
||||||
|
- [ ] `A` record `www.cameleer.io` → Hetzner IP. **Proxied (orange).**
|
||||||
|
- [ ] `A` record `@` (apex) → Hetzner IP. **Proxied (orange).**
|
||||||
|
- [ ] `A`/`CNAME` for `auth.cameleer.io` → SaaS ingress. **Proxied.**
|
||||||
|
- [ ] `A`/`CNAME` for `platform.cameleer.io` → SaaS ingress. **Proxied.**
|
||||||
|
- [ ] NO bare MX. If email is needed at `@cameleer.io`, use **Cloudflare Email Routing** or a distinct hostname on a different provider.
|
||||||
|
|
||||||
|
### SSL/TLS
|
||||||
|
- [ ] Mode: **Full (strict)**.
|
||||||
|
- [ ] Minimum TLS: **1.2**.
|
||||||
|
- [ ] TLS 1.3: **on**.
|
||||||
|
- [ ] Always Use HTTPS: **on**.
|
||||||
|
- [ ] Automatic HTTPS Rewrites: **on**.
|
||||||
|
- [ ] HSTS: `max-age=31536000; includeSubDomains; preload`. (Add the domain to `https://hstspreload.org/` only after the site is stable and serving HSTS cleanly for a couple of weeks.)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- [ ] WAF → **Cloudflare Managed Ruleset**: enabled (Free plan includes this since 2024).
|
||||||
|
- [ ] Bot Fight Mode: **on**.
|
||||||
|
- [ ] Browser Integrity Check: **on**.
|
||||||
|
- [ ] Security Level: **medium**.
|
||||||
|
- [ ] Email Obfuscation: **on**.
|
||||||
|
- [ ] Rate Limiting rule: 20 req/min per IP on `/*` (marketing pages).
|
||||||
|
|
||||||
|
### Transform Rules (edge-level security headers)
|
||||||
|
|
||||||
|
Create a Transform Rule — "Modify Response Header" — matching `http.host eq "www.cameleer.io"`:
|
||||||
|
|
||||||
|
| Operation | Header | Value |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Set | `Content-Security-Policy` | `default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'` |
|
||||||
|
| Set | `X-Content-Type-Options` | `nosniff` |
|
||||||
|
| Set | `X-Frame-Options` | `DENY` |
|
||||||
|
| Set | `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||||
|
| Set | `Permissions-Policy` | `geolocation=(), microphone=(), camera=(), payment=(), usb=()` |
|
||||||
|
|
||||||
|
### Page Rules / Redirect
|
||||||
|
- [ ] `cameleer.io/*` → `https://www.cameleer.io/$1` (301 permanent).
|
||||||
|
|
||||||
|
## 3. Gitea Actions secrets (in the repo settings)
|
||||||
|
|
||||||
|
Add these under Repository settings → Actions → Secrets (or variables):
|
||||||
|
|
||||||
|
| Name | Type | Value |
|
||||||
|
|------|------|-------|
|
||||||
|
| `SFTP_HOST` | secret | Hetzner SSH hostname |
|
||||||
|
| `SFTP_USER` | secret | Hetzner SSH user |
|
||||||
|
| `SFTP_PATH` | secret | Absolute path to document root (e.g., `/usr/home/cameleer/public_html/www.cameleer.io`) |
|
||||||
|
| `SFTP_KEY` | secret | Contents of `~/.ssh/cameleer-website-deploy` (private key, PEM) |
|
||||||
|
| `SFTP_KNOWN_HOSTS` | secret | Contents of `hetzner-known-hosts.txt` (captured via `ssh-keyscan`) |
|
||||||
|
| `PUBLIC_AUTH_SIGNIN_URL` | variable | `https://auth.cameleer.io/sign-in` |
|
||||||
|
| `PUBLIC_AUTH_SIGNUP_URL` | variable | `https://auth.cameleer.io/sign-in?first_screen=register` |
|
||||||
|
| `PUBLIC_SALES_EMAIL` | variable | `sales@cameleer.io` (or whatever sales alias you set up) |
|
||||||
|
|
||||||
|
## 4. Content TBD — before go-live
|
||||||
|
|
||||||
|
- [ ] Fill in `src/pages/imprint.astro` `operator` object with real legal details.
|
||||||
|
- [ ] Fill in `operatorContact` in `src/pages/privacy.astro`.
|
||||||
|
- [ ] Review the "Why us" / nJAMS wording in `src/components/sections/WhyUs.astro` for trademark safety.
|
||||||
|
- [ ] Confirm MID-tier retention: spec says **7 days**; `cameleer-saas/HOWTO.md` says **30 days**. Reconcile one side or the other.
|
||||||
|
|
||||||
|
## 5. First deploy
|
||||||
|
|
||||||
|
1. Merge a PR to `main`.
|
||||||
|
2. Watch the Gitea Actions run: `build`, then `deploy`.
|
||||||
|
3. The workflow includes a post-deploy smoke check — if HSTS / CSP / XFO are missing from the live response, the deploy fails and must be debugged at the Cloudflare Transform Rule layer.
|
||||||
|
4. Manually verify:
|
||||||
|
- `curl -sI https://www.cameleer.io/` returns all six security headers.
|
||||||
|
- `https://cameleer.io/` → `https://www.cameleer.io/` 301 redirect.
|
||||||
|
- Open the site in an incognito window on desktop + mobile.
|
||||||
44
README.md
Normal file
44
README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# cameleer-website
|
||||||
|
|
||||||
|
Marketing site for [cameleer.io](https://www.cameleer.io) — zero-code observability for Apache Camel.
|
||||||
|
|
||||||
|
This is a **static** Astro 5 site. Hosted on Hetzner Webhosting L, fronted by Cloudflare, deployed via Gitea Actions.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run dev # http://localhost:4321
|
||||||
|
npm run test # vitest — auth config + middleware header tests
|
||||||
|
npm run build # produces dist/
|
||||||
|
npm run preview # serves dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quality gates (run in CI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint:html # html-validate on dist/
|
||||||
|
npm run lint:links # linkinator on dist/
|
||||||
|
npm run lh # Lighthouse CI (>=0.95 on all 4 categories)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
See `.env.example`. All are `PUBLIC_*` (build-time, embedded in HTML).
|
||||||
|
|
||||||
|
| Var | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| `PUBLIC_AUTH_SIGNIN_URL` | Logto sign-in URL (redirected to by "Sign in" buttons) |
|
||||||
|
| `PUBLIC_AUTH_SIGNUP_URL` | Logto sign-up URL (redirected to by "Start free trial") |
|
||||||
|
| `PUBLIC_SALES_EMAIL` | Sales email (`mailto:` target for "Talk to sales") |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Push to `main` → Gitea Actions runs tests, builds, lints, then `rsync`s `dist/` to Hetzner over SSH (ed25519 key, host-key-pinned). Rollback is `git revert && git push`.
|
||||||
|
|
||||||
|
See [`OPERATOR-CHECKLIST.md`](./OPERATOR-CHECKLIST.md) for the one-time Hetzner + Cloudflare setup.
|
||||||
|
|
||||||
|
## Design & plan
|
||||||
|
|
||||||
|
- `docs/superpowers/specs/2026-04-24-cameleer-website-design.md` — the approved spec.
|
||||||
|
- `docs/superpowers/plans/2026-04-24-cameleer-website.md` — the implementation plan that built this repo.
|
||||||
24
astro.config.mjs
Normal file
24
astro.config.mjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import tailwind from '@astrojs/tailwind';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://www.cameleer.io',
|
||||||
|
output: 'static',
|
||||||
|
trailingSlash: 'ignore',
|
||||||
|
build: {
|
||||||
|
// 'directory' outputs <page>/index.html so extensionless URLs like /pricing
|
||||||
|
// resolve natively under Apache without MultiViews or rewrite rules.
|
||||||
|
format: 'directory',
|
||||||
|
assets: 'assets',
|
||||||
|
inlineStylesheets: 'auto',
|
||||||
|
},
|
||||||
|
compressHTML: true,
|
||||||
|
integrations: [
|
||||||
|
tailwind({ applyBaseStyles: false }),
|
||||||
|
],
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
cssMinify: 'lightningcss',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -113,6 +113,7 @@ Testing strategy by file type:
|
|||||||
"@lhci/cli": "^0.14.0",
|
"@lhci/cli": "^0.14.0",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"html-validate": "^8.18.0",
|
"html-validate": "^8.18.0",
|
||||||
|
"lightningcss": "^1.27.0",
|
||||||
"linkinator": "^6.1.0",
|
"linkinator": "^6.1.0",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
@@ -378,7 +379,7 @@ export default defineConfig({
|
|||||||
- [ ] **Step 2: Write the failing test `src/config/auth.test.ts`**
|
- [ ] **Step 2: Write the failing test `src/config/auth.test.ts`**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { resolveAuthConfig } from './auth';
|
import { resolveAuthConfig } from './auth';
|
||||||
|
|
||||||
describe('resolveAuthConfig', () => {
|
describe('resolveAuthConfig', () => {
|
||||||
@@ -416,6 +417,21 @@ describe('resolveAuthConfig', () => {
|
|||||||
})).toThrow(/PUBLIC_SALES_EMAIL/);
|
})).toThrow(/PUBLIC_SALES_EMAIL/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws if PUBLIC_AUTH_SIGNUP_URL is missing', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
})).toThrow(/PUBLIC_AUTH_SIGNUP_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if PUBLIC_AUTH_SIGNUP_URL is not https', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'http://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
})).toThrow(/must be https/);
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes signUpUrl distinct from signInUrl', () => {
|
it('exposes signUpUrl distinct from signInUrl', () => {
|
||||||
const cfg = resolveAuthConfig({
|
const cfg = resolveAuthConfig({
|
||||||
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
@@ -519,7 +535,7 @@ git commit -m "Add auth URL config module with validation (TDD)"
|
|||||||
- Create: `src/middleware.ts`
|
- Create: `src/middleware.ts`
|
||||||
- Create: `src/middleware.test.ts`
|
- Create: `src/middleware.test.ts`
|
||||||
|
|
||||||
**Why a middleware and headers at the edge?** Defense in depth — Cloudflare Transform Rules will also emit these headers at the edge, but emitting them from the origin means (a) they're visible in local `preview` and (b) the site stays hardened even if the CF config drifts.
|
**Why a middleware and headers at the edge?** Defense in depth. Astro's `output: 'static'` does NOT exercise middleware at `astro preview` time (preview serves static files directly) — so the middleware will only activate if we ever switch to SSR or Cloudflare Workers output. The real runtime header enforcement comes from Cloudflare Transform Rules (see spec §5.3). The middleware is kept so (a) we retain the invariant in version control, (b) it activates automatically if the output target ever changes, and (c) the pure `buildSecurityHeaders` function is unit-testable.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test `src/middleware.test.ts`**
|
- [ ] **Step 1: Write the failing test `src/middleware.test.ts`**
|
||||||
|
|
||||||
@@ -651,17 +667,13 @@ npm test
|
|||||||
|
|
||||||
Expected: all 7 tests PASS.
|
Expected: all 7 tests PASS.
|
||||||
|
|
||||||
- [ ] **Step 5: Smoke test against `astro preview`**
|
- [ ] **Step 5: Verification note (no live smoke — headers come from Cloudflare)**
|
||||||
|
|
||||||
```bash
|
Astro's `output: 'static'` does not run middleware at `astro preview` time, so a `curl` against the local preview server will NOT show these headers. This is expected, not a bug.
|
||||||
npm run build
|
|
||||||
npm run preview &
|
|
||||||
sleep 2
|
|
||||||
curl -sI http://localhost:4321/ | grep -iE '^(content-security-policy|x-frame-options|strict-transport-security|referrer-policy|permissions-policy|x-content-type-options)'
|
|
||||||
kill %1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all six headers printed. If any are missing, the middleware is not wired — check `astro.config.mjs` doesn't disable middleware.
|
The middleware exists to (a) keep the header contract in version control, (b) activate automatically if the output target switches to SSR or Cloudflare Workers, and (c) make the pure `buildSecurityHeaders` function unit-testable.
|
||||||
|
|
||||||
|
Production header enforcement is handled by Cloudflare Transform Rules (see `OPERATOR-CHECKLIST.md` Task 21). The post-deploy smoke test in the Gitea Actions workflow (Task 20) validates the headers are actually present on the live `https://www.cameleer.io/`.
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
@@ -1120,7 +1132,7 @@ import TopographicBg from '../TopographicBg.astro';
|
|||||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-6">
|
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-6">
|
||||||
Observability · Apache Camel
|
Observability · Apache Camel
|
||||||
</p>
|
</p>
|
||||||
<h1 class="font-display font-bold text-text mb-6">
|
<h1 class="text-display font-bold text-text mb-6">
|
||||||
See every route.<br />
|
See every route.<br />
|
||||||
Reach into every flow.
|
Reach into every flow.
|
||||||
</h1>
|
</h1>
|
||||||
@@ -1261,7 +1273,7 @@ const steps: Step[] = [
|
|||||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||||
<div class="max-w-2xl mb-16">
|
<div class="max-w-2xl mb-16">
|
||||||
<p class="text-cyan font-mono text-xs tracking-[0.25em] uppercase mb-4">For engineers</p>
|
<p class="text-cyan font-mono text-xs tracking-[0.25em] uppercase mb-4">For engineers</p>
|
||||||
<h2 class="font-hero font-bold text-text mb-4">How it works</h2>
|
<h2 class="text-hero font-bold text-text mb-4">How it works</h2>
|
||||||
<p class="text-text-muted text-lg">Three steps. No code changes. Works across Camel 4.x.</p>
|
<p class="text-text-muted text-lg">Three steps. No code changes. Works across Camel 4.x.</p>
|
||||||
</div>
|
</div>
|
||||||
<ol class="grid md:grid-cols-3 gap-6 md:gap-8">
|
<ol class="grid md:grid-cols-3 gap-6 md:gap-8">
|
||||||
@@ -1313,7 +1325,7 @@ git commit -m "Add HowItWorks section — 3-step engineer-facing walkthrough"
|
|||||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||||
<div class="max-w-2xl mb-16">
|
<div class="max-w-2xl mb-16">
|
||||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Why Cameleer</p>
|
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Why Cameleer</p>
|
||||||
<h2 class="font-hero font-bold text-text mb-4">
|
<h2 class="text-hero font-bold text-text mb-4">
|
||||||
A purpose-built tool, from the team that has built integration observability before.
|
A purpose-built tool, from the team that has built integration observability before.
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -1416,7 +1428,7 @@ const tiers: Tier[] = [
|
|||||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||||
<div class="max-w-2xl mb-12">
|
<div class="max-w-2xl mb-12">
|
||||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
||||||
<h2 class="font-hero font-bold text-text mb-4">Start free. Grow when you need to.</h2>
|
<h2 class="text-hero font-bold text-text mb-4">Start free. Grow when you need to.</h2>
|
||||||
<p class="text-text-muted text-lg">
|
<p class="text-text-muted text-lg">
|
||||||
No credit card for the trial.
|
No credit card for the trial.
|
||||||
<a href="/pricing" class="text-accent hover:underline">See full comparison →</a>
|
<a href="/pricing" class="text-accent hover:underline">See full comparison →</a>
|
||||||
@@ -1477,7 +1489,7 @@ import TopographicBg from '../TopographicBg.astro';
|
|||||||
<section class="relative overflow-hidden">
|
<section class="relative overflow-hidden">
|
||||||
<TopographicBg opacity={0.18} lines={6} />
|
<TopographicBg opacity={0.18} lines={6} />
|
||||||
<div class="relative max-w-content mx-auto px-6 py-24 md:py-32 text-center">
|
<div class="relative max-w-content mx-auto px-6 py-24 md:py-32 text-center">
|
||||||
<h2 class="font-display font-bold text-text mb-6">
|
<h2 class="text-display font-bold text-text mb-6">
|
||||||
Start seeing your routes.
|
Start seeing your routes.
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-lg md:text-xl text-text-muted max-w-prose mx-auto mb-10">
|
<p class="text-lg md:text-xl text-text-muted max-w-prose mx-auto mb-10">
|
||||||
@@ -1630,7 +1642,7 @@ const tiers: FullTier[] = [
|
|||||||
<TopographicBg opacity={0.12} lines={8} />
|
<TopographicBg opacity={0.12} lines={8} />
|
||||||
<div class="relative max-w-content mx-auto px-6 pt-20 pb-12">
|
<div class="relative max-w-content mx-auto px-6 pt-20 pb-12">
|
||||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
||||||
<h1 class="font-display font-bold text-text mb-6">Priced so engineers can say yes.</h1>
|
<h1 class="text-display font-bold text-text mb-6">Priced so engineers can say yes.</h1>
|
||||||
<p class="text-lg text-text-muted max-w-prose">
|
<p class="text-lg text-text-muted max-w-prose">
|
||||||
Start free for 14 days. No credit card required. Move up when you need more apps, longer retention, or enterprise features.
|
Start free for 14 days. No credit card required. Move up when you need more apps, longer retention, or enterprise features.
|
||||||
</p>
|
</p>
|
||||||
@@ -1732,7 +1744,7 @@ const operator = {
|
|||||||
>
|
>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
||||||
<h1 class="font-hero font-bold text-text mb-8">Imprint</h1>
|
<h1 class="text-hero font-bold text-text mb-8">Imprint</h1>
|
||||||
|
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<h2 class="text-lg font-bold text-text mb-3">Information pursuant to § 5 TMG / § 5 DDG</h2>
|
<h2 class="text-lg font-bold text-text mb-3">Information pursuant to § 5 TMG / § 5 DDG</h2>
|
||||||
@@ -1833,7 +1845,7 @@ const lastUpdated = '2026-04-24';
|
|||||||
>
|
>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
||||||
<h1 class="font-hero font-bold text-text mb-2">Privacy Policy</h1>
|
<h1 class="text-hero font-bold text-text mb-2">Privacy Policy</h1>
|
||||||
<p class="text-text-faint text-sm mb-10">Last updated: {lastUpdated}</p>
|
<p class="text-text-faint text-sm mb-10">Last updated: {lastUpdated}</p>
|
||||||
|
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
|
|||||||
32
lighthouserc.cjs
Normal file
32
lighthouserc.cjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module.exports = {
|
||||||
|
ci: {
|
||||||
|
collect: {
|
||||||
|
staticDistDir: './dist',
|
||||||
|
url: [
|
||||||
|
'http://localhost/index.html',
|
||||||
|
'http://localhost/pricing/index.html',
|
||||||
|
'http://localhost/imprint/index.html',
|
||||||
|
'http://localhost/privacy/index.html',
|
||||||
|
],
|
||||||
|
numberOfRuns: 3,
|
||||||
|
settings: {
|
||||||
|
preset: 'desktop',
|
||||||
|
// Flags required when Chromium runs inside a CI container or as root
|
||||||
|
// (Gitea act_runner on arm64 uses containers). --headless=new is the
|
||||||
|
// modern Chromium headless mode. CHROME_PATH is set by the workflow.
|
||||||
|
chromeFlags: '--no-sandbox --headless=new --disable-gpu --disable-dev-shm-usage',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assert: {
|
||||||
|
assertions: {
|
||||||
|
'categories:performance': ['error', { minScore: 0.95 }],
|
||||||
|
'categories:accessibility': ['error', { minScore: 0.95 }],
|
||||||
|
'categories:best-practices': ['error', { minScore: 0.95 }],
|
||||||
|
'categories:seo': ['error', { minScore: 0.95 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
target: 'temporary-public-storage',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
13
linkinator.config.json
Normal file
13
linkinator.config.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"recurse": true,
|
||||||
|
"silent": true,
|
||||||
|
"skip": [
|
||||||
|
"^https://auth\\.cameleer\\.io",
|
||||||
|
"^https://platform\\.cameleer\\.io",
|
||||||
|
"^https://www\\.cameleer\\.io",
|
||||||
|
"^mailto:",
|
||||||
|
"^https://ec\\.europa\\.eu"
|
||||||
|
],
|
||||||
|
"retry": true,
|
||||||
|
"concurrency": 10
|
||||||
|
}
|
||||||
13289
package-lock.json
generated
Normal file
13289
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "cameleer-website",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint:html": "html-validate \"dist/**/*.html\"",
|
||||||
|
"lint:links": "linkinator dist --recurse --silent",
|
||||||
|
"lh": "lhci autorun"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
|
"@fontsource/dm-sans": "^5.0.21",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.0.21",
|
||||||
|
"astro": "^5.0.0",
|
||||||
|
"tailwindcss": "^3.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@lhci/cli": "^0.14.0",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"html-validate": "^8.18.0",
|
||||||
|
"lightningcss": "^1.27.0",
|
||||||
|
"linkinator": "^6.1.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vitest": "^1.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
public/.htaccess
Normal file
55
public/.htaccess
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# ---------------------------------------------------------------
|
||||||
|
# www.cameleer.io — Apache config at the Hetzner origin.
|
||||||
|
# Defense in depth: Cloudflare handles most of this at the edge;
|
||||||
|
# these rules make sure the origin is hardened even without the CDN.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
# Enable rewriting
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Force HTTPS — redundant with Cloudflare but belts-and-braces.
|
||||||
|
RewriteCond %{HTTPS} !=on
|
||||||
|
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||||
|
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
|
# Redirect apex -> www.
|
||||||
|
RewriteCond %{HTTP_HOST} ^cameleer\.io$ [NC]
|
||||||
|
RewriteRule ^(.*)$ https://www.cameleer.io/$1 [L,R=301]
|
||||||
|
|
||||||
|
# Disable directory listings.
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# Block access to dotfiles and sensitive extensions that should never be here.
|
||||||
|
<FilesMatch "^\.|\.(env|ini|log|sh|bak|sql|git)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Prevent MIME sniffing, clickjacking, etc. (primary copy also comes from Astro middleware
|
||||||
|
# and Cloudflare Transform Rules — these apply if either layer is bypassed).
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set X-Frame-Options "DENY"
|
||||||
|
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
|
||||||
|
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
|
||||||
|
# Cache hashed build assets aggressively; HTML must be revalidated.
|
||||||
|
<FilesMatch "\.(css|js|woff2|svg|png|jpg|jpeg|webp|ico)$">
|
||||||
|
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
</FilesMatch>
|
||||||
|
<FilesMatch "\.html$">
|
||||||
|
Header set Cache-Control "public, max-age=3600, must-revalidate"
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Remove Server header leak where possible.
|
||||||
|
Header unset X-Powered-By
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Compression (Hetzner supports mod_deflate).
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml text/plain
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Custom error pages (optional — fall back to default if not present).
|
||||||
|
ErrorDocument 404 /404.html
|
||||||
|
ErrorDocument 403 /404.html
|
||||||
8
public/favicon.svg
Normal file
8
public/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#060a13"/>
|
||||||
|
<g fill="none" stroke="#f0b429" stroke-width="1.4" stroke-linecap="round">
|
||||||
|
<path d="M4 10 Q10 6 16 12 T28 10"/>
|
||||||
|
<path d="M4 16 Q10 12 16 18 T28 16"/>
|
||||||
|
<path d="M4 22 Q10 18 16 24 T28 22"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
13
public/og-image.svg
Normal file
13
public/og-image.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
|
||||||
|
<rect width="1200" height="630" fill="#060a13"/>
|
||||||
|
<g fill="none" stroke="#f0b429" stroke-width="1" opacity="0.2">
|
||||||
|
<path d="M0,120 Q300,60 600,150 T1200,120"/>
|
||||||
|
<path d="M0,240 Q300,180 600,270 T1200,240"/>
|
||||||
|
<path d="M0,360 Q300,300 600,390 T1200,360"/>
|
||||||
|
<path d="M0,480 Q300,420 600,510 T1200,480"/>
|
||||||
|
</g>
|
||||||
|
<text x="80" y="260" fill="#f0b429" font-family="'DM Sans', sans-serif" font-size="22" letter-spacing="6">OBSERVABILITY · APACHE CAMEL</text>
|
||||||
|
<text x="80" y="360" fill="#e8eaed" font-family="'DM Sans', sans-serif" font-size="72" font-weight="700">See every route.</text>
|
||||||
|
<text x="80" y="440" fill="#e8eaed" font-family="'DM Sans', sans-serif" font-size="72" font-weight="700">Reach into every flow.</text>
|
||||||
|
<text x="80" y="540" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="26">cameleer.io</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 921 B |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
4
src/__mocks__/astro-middleware.ts
Normal file
4
src/__mocks__/astro-middleware.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** Minimal stub for astro:middleware used in Vitest only. */
|
||||||
|
export function defineMiddleware(fn: unknown) {
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
47
src/components/CTAButtons.astro
Normal file
47
src/components/CTAButtons.astro
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
import { getAuthConfig } from '../config/auth';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: 'primary' | 'inverted';
|
||||||
|
size?: 'md' | 'lg';
|
||||||
|
showSecondary?: boolean;
|
||||||
|
primaryLabel?: string;
|
||||||
|
secondaryLabel?: string;
|
||||||
|
secondaryHref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = getAuthConfig();
|
||||||
|
|
||||||
|
const {
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
showSecondary = true,
|
||||||
|
primaryLabel = 'Start free trial',
|
||||||
|
secondaryLabel = 'Sign in',
|
||||||
|
secondaryHref = auth.signInUrl,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const padY = size === 'lg' ? 'py-3' : 'py-2';
|
||||||
|
const padX = size === 'lg' ? 'px-6' : 'px-5';
|
||||||
|
const fontSize = size === 'lg' ? 'text-base' : 'text-sm';
|
||||||
|
---
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={auth.signUpUrl}
|
||||||
|
class={`inline-flex items-center justify-center rounded ${padX} ${padY} ${fontSize} font-semibold transition-colors
|
||||||
|
${variant === 'primary'
|
||||||
|
? 'bg-accent text-bg hover:bg-accent-muted focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg'
|
||||||
|
: 'bg-bg text-accent border border-accent hover:bg-accent hover:text-bg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{primaryLabel}
|
||||||
|
</a>
|
||||||
|
{showSecondary && (
|
||||||
|
<a
|
||||||
|
href={secondaryHref}
|
||||||
|
class={`inline-flex items-center justify-center rounded ${padX} ${padY} ${fontSize} font-medium text-text border border-border-strong hover:border-accent hover:text-accent transition-colors`}
|
||||||
|
>
|
||||||
|
{secondaryLabel}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
95
src/components/RouteDiagram.astro
Normal file
95
src/components/RouteDiagram.astro
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
// A stylized Camel route topology. Two linked routes with a cross-route
|
||||||
|
// (cyan, dashed) reference — the signature Cameleer diagnostic that a
|
||||||
|
// generic APM cannot produce.
|
||||||
|
---
|
||||||
|
<div class="relative w-full aspect-[16/10] max-w-3xl mx-auto">
|
||||||
|
<svg viewBox="0 0 800 500" class="w-full h-full" role="img" aria-label="Camel route topology with cross-route reference">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow-amber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#f0b429"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-cyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#5cc8ff"/>
|
||||||
|
</marker>
|
||||||
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="4" result="b"/>
|
||||||
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Route labels */}
|
||||||
|
<text x="40" y="80" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="13" letter-spacing="1">▸ order-ingest</text>
|
||||||
|
<text x="40" y="340" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="13" letter-spacing="1">▸ pricing-enrich</text>
|
||||||
|
|
||||||
|
{/* Route 1 edges */}
|
||||||
|
<line x1="100" y1="130" x2="230" y2="130" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
<line x1="280" y1="130" x2="380" y2="90" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
<line x1="280" y1="130" x2="380" y2="170" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
<line x1="430" y1="90" x2="560" y2="90" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
<line x1="430" y1="170" x2="560" y2="170" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
<line x1="610" y1="130" x2="720" y2="130" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
|
||||||
|
{/* Route 1 nodes */}
|
||||||
|
<g>
|
||||||
|
<circle cx="90" cy="130" r="18" fill="#060a13" stroke="#f0b429" stroke-width="2" filter="url(#glow)"/>
|
||||||
|
<text x="90" y="110" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">kafka</text>
|
||||||
|
<text x="90" y="160" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="10" text-anchor="middle">from</text>
|
||||||
|
|
||||||
|
<rect x="240" y="115" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||||
|
<text x="265" y="134" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">choice</text>
|
||||||
|
|
||||||
|
<rect x="380" y="75" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||||
|
<text x="405" y="94" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">bean</text>
|
||||||
|
<text x="405" y="65" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="9" text-anchor="middle">premium</text>
|
||||||
|
|
||||||
|
<rect x="380" y="155" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||||
|
<text x="405" y="174" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">bean</text>
|
||||||
|
<text x="405" y="206" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="9" text-anchor="middle">standard</text>
|
||||||
|
|
||||||
|
<rect x="560" y="75" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||||
|
<text x="585" y="94" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">log</text>
|
||||||
|
|
||||||
|
<rect x="560" y="155" width="50" height="30" rx="4" fill="#060a13" stroke="#5cc8ff" stroke-width="1.8"/>
|
||||||
|
<text x="585" y="174" fill="#5cc8ff" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">http</text>
|
||||||
|
|
||||||
|
<circle cx="735" cy="130" r="14" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||||
|
<text x="735" y="160" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="10" text-anchor="middle">to</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Cross-route dashed cyan link */}
|
||||||
|
<path
|
||||||
|
d="M 585 185 C 585 290, 300 250, 230 340"
|
||||||
|
stroke="#5cc8ff"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-dasharray="6 4"
|
||||||
|
fill="none"
|
||||||
|
marker-end="url(#arrow-cyan)"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
<text x="430" y="255" fill="#5cc8ff" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">cross-route correlation</text>
|
||||||
|
|
||||||
|
{/* Route 2 edges */}
|
||||||
|
<line x1="100" y1="390" x2="230" y2="390" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
<line x1="280" y1="390" x2="380" y2="390" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
<line x1="430" y1="390" x2="560" y2="390" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
<line x1="610" y1="390" x2="720" y2="390" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||||
|
|
||||||
|
{/* Route 2 nodes */}
|
||||||
|
<g>
|
||||||
|
<circle cx="230" cy="390" r="14" fill="#060a13" stroke="#5cc8ff" stroke-width="1.8"/>
|
||||||
|
<text x="230" y="418" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="10" text-anchor="middle">rest</text>
|
||||||
|
<text x="230" y="370" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">from</text>
|
||||||
|
|
||||||
|
<rect x="330" y="375" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||||
|
<text x="355" y="394" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">enrich</text>
|
||||||
|
|
||||||
|
<rect x="480" y="375" width="80" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||||
|
<text x="520" y="394" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">transform</text>
|
||||||
|
|
||||||
|
<circle cx="735" cy="390" r="14" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||||
|
<text x="735" y="418" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="10" text-anchor="middle">to</text>
|
||||||
|
<text x="735" y="370" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">kafka</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
23
src/components/SiteFooter.astro
Normal file
23
src/components/SiteFooter.astro
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
---
|
||||||
|
<footer class="border-t border-border mt-24">
|
||||||
|
<div class="max-w-content mx-auto px-6 py-12 flex flex-col md:flex-row md:items-center md:justify-between gap-8">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 32 32" aria-hidden="true">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#0c111a"/>
|
||||||
|
<g fill="none" stroke="#f0b429" stroke-width="1.6" stroke-linecap="round">
|
||||||
|
<path d="M4 10 Q10 6 16 12 T28 10"/>
|
||||||
|
<path d="M4 16 Q10 12 16 18 T28 16"/>
|
||||||
|
<path d="M4 22 Q10 18 16 24 T28 22"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span class="text-text-muted text-sm">© {year} Cameleer</span>
|
||||||
|
</div>
|
||||||
|
<nav class="flex items-center gap-8 text-sm text-text-muted" aria-label="Footer">
|
||||||
|
<a href="/pricing" class="hover:text-text transition-colors">Pricing</a>
|
||||||
|
<a href="/imprint" class="hover:text-text transition-colors">Imprint</a>
|
||||||
|
<a href="/privacy" class="hover:text-text transition-colors">Privacy</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
22
src/components/SiteHeader.astro
Normal file
22
src/components/SiteHeader.astro
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
import CTAButtons from './CTAButtons.astro';
|
||||||
|
---
|
||||||
|
<header class="sticky top-0 z-40 backdrop-blur-md bg-bg/80 border-b border-border">
|
||||||
|
<div class="max-w-content mx-auto px-6 h-16 flex items-center justify-between gap-6">
|
||||||
|
<a href="/" class="flex items-center gap-2 group" aria-label="Cameleer home">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 32 32" aria-hidden="true">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#0c111a"/>
|
||||||
|
<g fill="none" stroke="#f0b429" stroke-width="1.6" stroke-linecap="round">
|
||||||
|
<path d="M4 10 Q10 6 16 12 T28 10"/>
|
||||||
|
<path d="M4 16 Q10 12 16 18 T28 16"/>
|
||||||
|
<path d="M4 22 Q10 18 16 24 T28 22"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span class="font-sans font-bold text-lg tracking-tight text-text group-hover:text-accent transition-colors">Cameleer</span>
|
||||||
|
</a>
|
||||||
|
<nav class="flex items-center gap-8 text-sm" aria-label="Primary">
|
||||||
|
<a href="/pricing" class="text-text-muted hover:text-text transition-colors">Pricing</a>
|
||||||
|
</nav>
|
||||||
|
<CTAButtons size="md" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
26
src/components/TopographicBg.astro
Normal file
26
src/components/TopographicBg.astro
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
opacity?: number;
|
||||||
|
lines?: number;
|
||||||
|
}
|
||||||
|
const { opacity = 0.12, lines = 8 } = Astro.props;
|
||||||
|
|
||||||
|
const paths: string[] = [];
|
||||||
|
const stepY = 100 / (lines + 1);
|
||||||
|
for (let i = 1; i <= lines; i++) {
|
||||||
|
const y = i * stepY;
|
||||||
|
const amp = 4 + (i % 3) * 2;
|
||||||
|
paths.push(`M0,${y} Q25,${y - amp} 50,${y + amp * 0.6} T100,${y}`);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<svg
|
||||||
|
class="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={`opacity:${opacity}`}
|
||||||
|
>
|
||||||
|
<g fill="none" stroke="#f0b429" stroke-width="0.15" vector-effect="non-scaling-stroke">
|
||||||
|
{paths.map((d) => <path d={d} />)}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
40
src/components/sections/DualValueProps.astro
Normal file
40
src/components/sections/DualValueProps.astro
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
interface Tile {
|
||||||
|
outcome: string;
|
||||||
|
capability: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tile.capability is a compile-time constant defined below — never feed
|
||||||
|
// user-supplied or CMS content into set:html further down (XSS risk).
|
||||||
|
const tiles: Tile[] = [
|
||||||
|
{
|
||||||
|
outcome: 'Cut debugging time in half.',
|
||||||
|
capability:
|
||||||
|
'Every processor on every route, timed to the nanosecond. Choice branches, splits, multicasts, and error handlers preserved as a proper execution tree — not a pile of log lines.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
outcome: 'Ship integrations with confidence.',
|
||||||
|
capability:
|
||||||
|
'Capture real payloads and replay them on demand. Deep-trace a specific correlation ID across services. Push trace settings to a running agent without a redeploy.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
outcome: 'Keep what you have built.',
|
||||||
|
capability:
|
||||||
|
'One `-javaagent` flag. No code changes, no SDK, no framework lock-in. Native understanding of 45+ EIP node types across Apache Camel 4.x.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
<section class="border-b border-border">
|
||||||
|
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||||
|
<div class="grid md:grid-cols-3 gap-6 md:gap-8">
|
||||||
|
{tiles.map((tile) => (
|
||||||
|
<div class="rounded-lg border border-border bg-bg-elevated p-7 md:p-8 hover:border-border-strong transition-colors">
|
||||||
|
<h2 class="text-xl md:text-2xl font-bold text-text mb-3 leading-snug">
|
||||||
|
{tile.outcome}
|
||||||
|
</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed" set:html={tile.capability.replace(/`([^`]+)`/g, '<code class="font-mono text-accent bg-bg border border-border rounded px-1 py-0.5 text-sm">$1</code>')}></p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
18
src/components/sections/FinalCTA.astro
Normal file
18
src/components/sections/FinalCTA.astro
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
import CTAButtons from '../CTAButtons.astro';
|
||||||
|
import TopographicBg from '../TopographicBg.astro';
|
||||||
|
---
|
||||||
|
<section class="relative overflow-hidden">
|
||||||
|
<TopographicBg opacity={0.18} lines={6} />
|
||||||
|
<div class="relative max-w-content mx-auto px-6 py-24 md:py-32 text-center">
|
||||||
|
<h2 class="text-display font-bold text-text mb-6">
|
||||||
|
Start seeing your routes.
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg md:text-xl text-text-muted max-w-prose mx-auto mb-10">
|
||||||
|
14-day free trial. Your first app, instrumented and live in under 10 minutes.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<CTAButtons size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
27
src/components/sections/Hero.astro
Normal file
27
src/components/sections/Hero.astro
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import CTAButtons from '../CTAButtons.astro';
|
||||||
|
import RouteDiagram from '../RouteDiagram.astro';
|
||||||
|
import TopographicBg from '../TopographicBg.astro';
|
||||||
|
---
|
||||||
|
<section class="relative overflow-hidden border-b border-border">
|
||||||
|
<TopographicBg opacity={0.14} lines={10} />
|
||||||
|
<div class="relative max-w-content mx-auto px-6 pt-20 pb-24 md:pt-28 md:pb-32">
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-6">
|
||||||
|
Observability · Apache Camel
|
||||||
|
</p>
|
||||||
|
<h1 class="text-display font-bold text-text mb-6">
|
||||||
|
See every route.<br />
|
||||||
|
Reach into every flow.
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg md:text-xl text-text-muted max-w-prose leading-relaxed mb-10">
|
||||||
|
Zero-code tracing, processor-level detail, and live control for Apache Camel —
|
||||||
|
from a single <code class="font-mono text-accent bg-bg-elevated border border-border rounded px-1.5 py-0.5 text-base">-javaagent</code> flag.
|
||||||
|
</p>
|
||||||
|
<CTAButtons size="lg" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-16 md:mt-20">
|
||||||
|
<RouteDiagram />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
48
src/components/sections/HowItWorks.astro
Normal file
48
src/components/sections/HowItWorks.astro
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
interface Step {
|
||||||
|
n: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: Step[] = [
|
||||||
|
{
|
||||||
|
n: '01',
|
||||||
|
title: 'Add the agent',
|
||||||
|
body: 'Drop the Cameleer agent JAR alongside your Camel app and start it with a single flag. That is the entire installation.',
|
||||||
|
code: 'java \\\n -javaagent:cameleer-agent.jar \\\n -jar your-camel-app.jar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: '02',
|
||||||
|
title: 'Launch your app',
|
||||||
|
body: 'Every route, processor, exchange, and route graph is discovered and reported automatically. Configurable redaction keeps sensitive fields out of the trace.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: '03',
|
||||||
|
title: 'See it in Mission Control',
|
||||||
|
body: 'Browse executions, tap live traffic, replay failed exchanges, and follow flows across services. Nothing to instrument, nothing to maintain.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
<section class="border-b border-border">
|
||||||
|
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||||
|
<div class="max-w-2xl mb-16">
|
||||||
|
<p class="text-cyan font-mono text-xs tracking-[0.25em] uppercase mb-4">For engineers</p>
|
||||||
|
<h2 class="text-hero font-bold text-text mb-4">How it works</h2>
|
||||||
|
<p class="text-text-muted text-lg">Three steps. No code changes. Works across Camel 4.x.</p>
|
||||||
|
</div>
|
||||||
|
<ol class="grid md:grid-cols-3 gap-6 md:gap-8">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<li class="relative rounded-lg border border-border bg-bg-elevated p-7">
|
||||||
|
<div class="font-mono text-accent text-sm tracking-wider mb-3">{step.n}</div>
|
||||||
|
<h3 class="text-lg font-bold text-text mb-3">{step.title}</h3>
|
||||||
|
<p class="text-text-muted leading-relaxed mb-4">{step.body}</p>
|
||||||
|
{step.code && (
|
||||||
|
<pre class="font-mono text-xs md:text-sm bg-bg border border-border rounded px-4 py-3 text-text-muted overflow-x-auto leading-relaxed"><code>{step.code}</code></pre>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
78
src/components/sections/PricingTeaser.astro
Normal file
78
src/components/sections/PricingTeaser.astro
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
import { getAuthConfig } from '../../config/auth';
|
||||||
|
|
||||||
|
interface Tier {
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
sub: string;
|
||||||
|
href: string;
|
||||||
|
cta: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = getAuthConfig();
|
||||||
|
|
||||||
|
const tiers: Tier[] = [
|
||||||
|
{
|
||||||
|
name: 'Trial',
|
||||||
|
price: 'Free',
|
||||||
|
sub: '14 days · 1 environment · 2 apps',
|
||||||
|
href: auth.signUpUrl,
|
||||||
|
cta: 'Start free trial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MID',
|
||||||
|
price: '20 € /mo',
|
||||||
|
sub: '2 environments · 10 apps · 7-day retention',
|
||||||
|
href: auth.signUpUrl,
|
||||||
|
cta: 'Start on MID',
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HIGH',
|
||||||
|
price: 'Contact',
|
||||||
|
sub: 'Unlimited envs · 50 apps · 90-day retention · Debugger, Replay',
|
||||||
|
href: auth.salesMailto,
|
||||||
|
cta: 'Talk to sales',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BUSINESS',
|
||||||
|
price: 'Contact',
|
||||||
|
sub: 'Unlimited everything · 365-day retention · all features',
|
||||||
|
href: auth.salesMailto,
|
||||||
|
cta: 'Talk to sales',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
<section class="border-b border-border">
|
||||||
|
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||||
|
<div class="max-w-2xl mb-12">
|
||||||
|
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
||||||
|
<h2 class="text-hero font-bold text-text mb-4">Start free. Grow when you need to.</h2>
|
||||||
|
<p class="text-text-muted text-lg">
|
||||||
|
No credit card for the trial.
|
||||||
|
<a href="/pricing" class="text-accent hover:underline">See full comparison →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
|
{tiers.map((tier) => (
|
||||||
|
<div class={`rounded-lg border bg-bg-elevated p-6 flex flex-col ${tier.highlight ? 'border-accent' : 'border-border'}`}>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="font-mono text-xs tracking-wider text-text-muted mb-2">{tier.name.toUpperCase()}</div>
|
||||||
|
<div class="text-2xl font-bold text-text">{tier.price}</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-text-muted text-sm leading-relaxed flex-grow mb-5">{tier.sub}</p>
|
||||||
|
<a
|
||||||
|
href={tier.href}
|
||||||
|
class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-semibold transition-colors
|
||||||
|
${tier.highlight
|
||||||
|
? 'bg-accent text-bg hover:bg-accent-muted'
|
||||||
|
: 'border border-border-strong text-text hover:border-accent hover:text-accent'}`}
|
||||||
|
>
|
||||||
|
{tier.cta}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
34
src/components/sections/WhyUs.astro
Normal file
34
src/components/sections/WhyUs.astro
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
// Final nJAMS-legacy wording is subject to Hendrik's trademark review before go-live
|
||||||
|
// (see docs/superpowers/specs/2026-04-24-cameleer-website-design.md §10).
|
||||||
|
---
|
||||||
|
<section class="border-b border-border">
|
||||||
|
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||||
|
<div class="max-w-2xl mb-16">
|
||||||
|
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Why Cameleer</p>
|
||||||
|
<h2 class="text-hero font-bold text-text mb-4">
|
||||||
|
A purpose-built tool, from the team that has built integration observability before.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid md:grid-cols-2 gap-8 md:gap-12">
|
||||||
|
<div class="rounded-lg border border-border bg-bg-elevated p-8">
|
||||||
|
<h3 class="text-xl font-bold text-text mb-4">Generic APMs do not understand Camel. Cameleer does.</h3>
|
||||||
|
<p class="text-text-muted leading-relaxed mb-4">
|
||||||
|
Our Java agent speaks 45+ Apache Camel EIP node types natively — choices, splits, multicasts, doTry, error handlers, dynamic endpoints, thread boundaries in async routes. It extracts your route topology as a first-class graph, not a pile of metrics.
|
||||||
|
</p>
|
||||||
|
<p class="text-text-muted leading-relaxed">
|
||||||
|
A bidirectional protocol lets the server push deep-trace requests, per-route recording toggles, and signed config changes back to running agents — turning passive observability into active control. Not something you build in a weekend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border bg-bg-elevated p-8">
|
||||||
|
<h3 class="text-xl font-bold text-text mb-4">Built by people who have shipped this class of product before.</h3>
|
||||||
|
<p class="text-text-muted leading-relaxed mb-4">
|
||||||
|
The Cameleer team spent years building and supporting integration monitoring for banks, insurers, and logistics operators. We know what integration teams actually need at 3 AM — and what they never use.
|
||||||
|
</p>
|
||||||
|
<p class="text-text-muted leading-relaxed">
|
||||||
|
Cameleer is what we would build today, purpose-built for Apache Camel — no legacy, no retrofit, no assumptions about a generic middleware platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
62
src/config/auth.test.ts
Normal file
62
src/config/auth.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveAuthConfig } from './auth';
|
||||||
|
|
||||||
|
describe('resolveAuthConfig', () => {
|
||||||
|
it('returns both URLs and sales email from env', () => {
|
||||||
|
const cfg = resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
});
|
||||||
|
expect(cfg.signInUrl).toBe('https://auth.cameleer.io/sign-in');
|
||||||
|
expect(cfg.signUpUrl).toBe('https://auth.cameleer.io/sign-in?first_screen=register');
|
||||||
|
expect(cfg.salesEmail).toBe('sales@cameleer.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if PUBLIC_AUTH_SIGNIN_URL is missing', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
})).toThrow(/PUBLIC_AUTH_SIGNIN_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if a URL is not https', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'http://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
})).toThrow(/must be https/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if sales email is not a valid mailto target', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'not-an-email',
|
||||||
|
})).toThrow(/PUBLIC_SALES_EMAIL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if PUBLIC_AUTH_SIGNUP_URL is missing', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
})).toThrow(/PUBLIC_AUTH_SIGNUP_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if PUBLIC_AUTH_SIGNUP_URL is not https', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'http://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
})).toThrow(/must be https/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes signUpUrl distinct from signInUrl', () => {
|
||||||
|
const cfg = resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
});
|
||||||
|
expect(cfg.signUpUrl).not.toBe(cfg.signInUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/config/auth.ts
Normal file
56
src/config/auth.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export interface AuthConfig {
|
||||||
|
signInUrl: string;
|
||||||
|
signUpUrl: string;
|
||||||
|
salesEmail: string;
|
||||||
|
salesMailto: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnvLike {
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL?: string;
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL?: string;
|
||||||
|
PUBLIC_SALES_EMAIL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireHttps(name: string, value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
if (!value.startsWith('https://')) {
|
||||||
|
throw new Error(`${name} must be https (got: ${value})`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEmail(name: string, value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
// RFC-5322-ish minimal check — we just want to catch typos, not validate against RFC.
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||||
|
throw new Error(`${name} must look like an email (got: ${value})`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthConfig(env: EnvLike): AuthConfig {
|
||||||
|
const signInUrl = requireHttps('PUBLIC_AUTH_SIGNIN_URL', env.PUBLIC_AUTH_SIGNIN_URL);
|
||||||
|
const signUpUrl = requireHttps('PUBLIC_AUTH_SIGNUP_URL', env.PUBLIC_AUTH_SIGNUP_URL);
|
||||||
|
const salesEmail = requireEmail('PUBLIC_SALES_EMAIL', env.PUBLIC_SALES_EMAIL);
|
||||||
|
return {
|
||||||
|
signInUrl,
|
||||||
|
signUpUrl,
|
||||||
|
salesEmail,
|
||||||
|
salesMailto: `mailto:${salesEmail}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy accessor for Astro usage. Not evaluated at module load (so vitest can
|
||||||
|
// import this file without the PUBLIC_* env vars being set). Each call after
|
||||||
|
// the first returns the cached config.
|
||||||
|
let _cached: AuthConfig | null = null;
|
||||||
|
export function getAuthConfig(): AuthConfig {
|
||||||
|
if (_cached === null) {
|
||||||
|
_cached = resolveAuthConfig(import.meta.env as unknown as EnvLike);
|
||||||
|
}
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
23
src/content/config.ts
Normal file
23
src/content/config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
|
||||||
|
// Scaffold only — no entries yet. Added now so future blog/docs work is pure content, not refactor.
|
||||||
|
const blog = defineCollection({
|
||||||
|
type: 'content',
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
publishedAt: z.date(),
|
||||||
|
draft: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = defineCollection({
|
||||||
|
type: 'content',
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
order: z.number().default(99),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { blog, docs };
|
||||||
0
src/content/en/.gitkeep
Normal file
0
src/content/en/.gitkeep
Normal file
53
src/layouts/BaseLayout.astro
Normal file
53
src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
import '../styles/global.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
canonicalPath?: string;
|
||||||
|
ogImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
canonicalPath = Astro.url.pathname,
|
||||||
|
ogImage = '/og-image.svg',
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const canonical = new URL(canonicalPath, Astro.site ?? 'https://www.cameleer.io').toString();
|
||||||
|
const ogUrl = new URL(ogImage, Astro.site ?? 'https://www.cameleer.io').toString();
|
||||||
|
---
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="bg-bg">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<meta name="theme-color" content="#060a13" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Cameleer" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonical} />
|
||||||
|
<meta property="og:image" content={ogUrl} />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={ogUrl} />
|
||||||
|
|
||||||
|
<meta name="robots" content="index,follow" />
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-bg text-text font-sans antialiased">
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
src/middleware.test.ts
Normal file
54
src/middleware.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildSecurityHeaders } from './middleware';
|
||||||
|
|
||||||
|
describe('buildSecurityHeaders', () => {
|
||||||
|
const headers = buildSecurityHeaders();
|
||||||
|
|
||||||
|
it('sets a strict Content-Security-Policy', () => {
|
||||||
|
const csp = headers['Content-Security-Policy'];
|
||||||
|
expect(csp).toContain("default-src 'self'");
|
||||||
|
expect(csp).toContain("script-src 'self'");
|
||||||
|
expect(csp).toContain("frame-ancestors 'none'");
|
||||||
|
expect(csp).toContain("form-action 'none'");
|
||||||
|
expect(csp).toContain("base-uri 'self'");
|
||||||
|
expect(csp).toContain("object-src 'none'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies framing', () => {
|
||||||
|
expect(headers['X-Frame-Options']).toBe('DENY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables MIME sniffing', () => {
|
||||||
|
expect(headers['X-Content-Type-Options']).toBe('nosniff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets a strict referrer policy', () => {
|
||||||
|
expect(headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables sensitive browser features', () => {
|
||||||
|
const pp = headers['Permissions-Policy'];
|
||||||
|
expect(pp).toContain('geolocation=()');
|
||||||
|
expect(pp).toContain('microphone=()');
|
||||||
|
expect(pp).toContain('camera=()');
|
||||||
|
expect(pp).toContain('payment=()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets HSTS with long max-age, subdomains, and preload', () => {
|
||||||
|
const hsts = headers['Strict-Transport-Security'];
|
||||||
|
expect(hsts).toContain('max-age=31536000');
|
||||||
|
expect(hsts).toContain('includeSubDomains');
|
||||||
|
expect(hsts).toContain('preload');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow inline scripts', () => {
|
||||||
|
// Script directive must not include 'unsafe-inline' — find it explicitly and assert.
|
||||||
|
const scriptDirective = headers['Content-Security-Policy']
|
||||||
|
.split(';')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.find(s => s.startsWith('script-src')) ?? '';
|
||||||
|
expect(scriptDirective).toContain("'self'");
|
||||||
|
expect(scriptDirective).not.toContain("'unsafe-inline'");
|
||||||
|
expect(scriptDirective).not.toContain("'unsafe-eval'");
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/middleware.ts
Normal file
55
src/middleware.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { defineMiddleware } from 'astro:middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the site-wide security header set. Astro does not attach these to
|
||||||
|
* statically-built responses at runtime on a shared host — but `preview` uses
|
||||||
|
* them locally, and when Cloudflare Transform Rules are configured (per
|
||||||
|
* docs/superpowers/specs/2026-04-24-cameleer-website-design.md §5.3) the
|
||||||
|
* edge re-emits the same set for the prod origin. Having both is defense
|
||||||
|
* in depth.
|
||||||
|
*/
|
||||||
|
export function buildSecurityHeaders(): Record<string, string> {
|
||||||
|
const csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
// Astro's scoped-style system injects inline style attributes at build time;
|
||||||
|
// 'unsafe-inline' here is required until we migrate to a hash/nonce strategy.
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"font-src 'self'",
|
||||||
|
"script-src 'self'",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
// No forms on this marketing site today (all auth redirects go to auth.cameleer.io
|
||||||
|
// as plain <a> navigations). If a future form is added, relax to 'self' or an allow-list.
|
||||||
|
"form-action 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
// Must match .htaccess and the Cloudflare Transform Rule in OPERATOR-CHECKLIST.md.
|
||||||
|
const permissionsPolicy = [
|
||||||
|
'geolocation=()',
|
||||||
|
'microphone=()',
|
||||||
|
'camera=()',
|
||||||
|
'payment=()',
|
||||||
|
'usb=()',
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Content-Security-Policy': csp,
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||||
|
'Permissions-Policy': permissionsPolicy,
|
||||||
|
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequest = defineMiddleware(async (_context, next) => {
|
||||||
|
const response = await next();
|
||||||
|
const headers = buildSecurityHeaders();
|
||||||
|
for (const [name, value] of Object.entries(headers)) {
|
||||||
|
response.headers.set(name, value);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
84
src/pages/imprint.astro
Normal file
84
src/pages/imprint.astro
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import SiteHeader from '../components/SiteHeader.astro';
|
||||||
|
import SiteFooter from '../components/SiteFooter.astro';
|
||||||
|
|
||||||
|
// Imprint (Impressum) per TMG §5 / DDG §5.
|
||||||
|
// Values prefixed "<TBD:" MUST be replaced with real operator data before go-live.
|
||||||
|
// See docs/superpowers/specs/2026-04-24-cameleer-website-design.md §6.4.
|
||||||
|
const operator = {
|
||||||
|
legalName: '<TBD: legal name of operating entity>',
|
||||||
|
streetAddress: '<TBD: street and number>',
|
||||||
|
postalCity: '<TBD: postal code and city>',
|
||||||
|
country: 'Germany',
|
||||||
|
email: '<TBD: contact email>',
|
||||||
|
phone: '<TBD: phone (optional but recommended)>',
|
||||||
|
vatId: '<TBD: VAT ID per §27a UStG, or "not applicable">',
|
||||||
|
registerEntry: '<TBD: commercial register + court, or "not applicable">',
|
||||||
|
responsibleForContent: '<TBD: responsible party per §18 Abs. 2 MStV>',
|
||||||
|
};
|
||||||
|
---
|
||||||
|
<BaseLayout
|
||||||
|
title="Imprint — Cameleer"
|
||||||
|
description="Legal imprint (Impressum) for Cameleer per German TMG §5 / DDG §5."
|
||||||
|
>
|
||||||
|
<SiteHeader />
|
||||||
|
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
||||||
|
<h1 class="text-hero font-bold text-text mb-8">Imprint</h1>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">Information pursuant to § 5 TMG / § 5 DDG</h2>
|
||||||
|
<address class="not-italic text-text-muted leading-relaxed">
|
||||||
|
{operator.legalName}<br />
|
||||||
|
{operator.streetAddress}<br />
|
||||||
|
{operator.postalCity}<br />
|
||||||
|
{operator.country}
|
||||||
|
</address>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">Contact</h2>
|
||||||
|
<ul class="text-text-muted space-y-1">
|
||||||
|
<li>Email: <span class="font-mono text-accent">{operator.email}</span></li>
|
||||||
|
<li>Phone: {operator.phone}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">VAT ID</h2>
|
||||||
|
<p class="text-text-muted">
|
||||||
|
VAT identification number pursuant to § 27 a UStG: <span class="font-mono">{operator.vatId}</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">Commercial register</h2>
|
||||||
|
<p class="text-text-muted">{operator.registerEntry}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">Responsible for content per § 18 Abs. 2 MStV</h2>
|
||||||
|
<p class="text-text-muted">{operator.responsibleForContent}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">EU dispute resolution</h2>
|
||||||
|
<p class="text-text-muted">
|
||||||
|
The European Commission provides a platform for online dispute resolution (ODR) at
|
||||||
|
<a href="https://ec.europa.eu/consumers/odr/" class="text-accent hover:underline">https://ec.europa.eu/consumers/odr/</a>.
|
||||||
|
We are not obligated and do not participate in dispute resolution proceedings before a consumer arbitration board.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">Liability for content and links</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed mb-4">
|
||||||
|
As a service provider, we are responsible for our own content on these pages in accordance with § 7 para. 1 TMG and general laws. According to §§ 8 to 10 TMG, however, we are not obligated to monitor transmitted or stored third-party information or to investigate circumstances that indicate unlawful activity.
|
||||||
|
</p>
|
||||||
|
<p class="text-text-muted leading-relaxed">
|
||||||
|
Our website contains links to external websites of third parties, on whose contents we have no influence. Therefore, we cannot assume any liability for these external contents. The respective provider or operator of the pages is always responsible for the content of linked pages.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</BaseLayout>
|
||||||
26
src/pages/index.astro
Normal file
26
src/pages/index.astro
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import SiteHeader from '../components/SiteHeader.astro';
|
||||||
|
import SiteFooter from '../components/SiteFooter.astro';
|
||||||
|
import Hero from '../components/sections/Hero.astro';
|
||||||
|
import DualValueProps from '../components/sections/DualValueProps.astro';
|
||||||
|
import HowItWorks from '../components/sections/HowItWorks.astro';
|
||||||
|
import WhyUs from '../components/sections/WhyUs.astro';
|
||||||
|
import PricingTeaser from '../components/sections/PricingTeaser.astro';
|
||||||
|
import FinalCTA from '../components/sections/FinalCTA.astro';
|
||||||
|
---
|
||||||
|
<BaseLayout
|
||||||
|
title="Cameleer — Zero-code observability for Apache Camel"
|
||||||
|
description="See every route. Reach into every flow. Zero-code tracing, processor-level detail, and live control for Apache Camel — from a single -javaagent flag."
|
||||||
|
>
|
||||||
|
<SiteHeader />
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<DualValueProps />
|
||||||
|
<HowItWorks />
|
||||||
|
<WhyUs />
|
||||||
|
<PricingTeaser />
|
||||||
|
<FinalCTA />
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</BaseLayout>
|
||||||
126
src/pages/pricing.astro
Normal file
126
src/pages/pricing.astro
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import SiteHeader from '../components/SiteHeader.astro';
|
||||||
|
import SiteFooter from '../components/SiteFooter.astro';
|
||||||
|
import TopographicBg from '../components/TopographicBg.astro';
|
||||||
|
import { getAuthConfig } from '../config/auth';
|
||||||
|
|
||||||
|
interface FullTier {
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
priceNote: string;
|
||||||
|
envs: string;
|
||||||
|
apps: string;
|
||||||
|
retention: string;
|
||||||
|
features: string[];
|
||||||
|
href: string;
|
||||||
|
cta: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = getAuthConfig();
|
||||||
|
|
||||||
|
const tiers: FullTier[] = [
|
||||||
|
{
|
||||||
|
name: 'Trial',
|
||||||
|
price: 'Free',
|
||||||
|
priceNote: '14 days',
|
||||||
|
envs: '1 environment',
|
||||||
|
apps: '2 apps',
|
||||||
|
retention: '1-day retention',
|
||||||
|
features: ['Route topology', 'Processor-level tracing', 'Payload capture with redaction'],
|
||||||
|
href: auth.signUpUrl,
|
||||||
|
cta: 'Start free trial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MID',
|
||||||
|
price: '20 €',
|
||||||
|
priceNote: 'per month',
|
||||||
|
envs: '2 environments',
|
||||||
|
apps: '10 apps',
|
||||||
|
retention: '7-day retention',
|
||||||
|
features: ['Everything in Trial', 'Data flow lineage', 'Cross-service correlation'],
|
||||||
|
href: auth.signUpUrl,
|
||||||
|
cta: 'Start on MID',
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HIGH',
|
||||||
|
price: 'Contact',
|
||||||
|
priceNote: 'sales',
|
||||||
|
envs: 'Unlimited environments',
|
||||||
|
apps: '50 apps',
|
||||||
|
retention: '90-day retention',
|
||||||
|
features: ['Everything in MID', 'Live debugger', 'Exchange replay', 'Live tap'],
|
||||||
|
href: auth.salesMailto,
|
||||||
|
cta: 'Talk to sales',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BUSINESS',
|
||||||
|
price: 'Contact',
|
||||||
|
priceNote: 'sales',
|
||||||
|
envs: 'Unlimited environments',
|
||||||
|
apps: 'Unlimited apps',
|
||||||
|
retention: '365-day retention',
|
||||||
|
features: ['Everything in HIGH', 'Priority support', 'SLA', 'Dedicated success contact'],
|
||||||
|
href: auth.salesMailto,
|
||||||
|
cta: 'Talk to sales',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
<BaseLayout
|
||||||
|
title="Pricing — Cameleer"
|
||||||
|
description="Simple pricing for Apache Camel observability. Free 14-day trial, then 20 €/month or enterprise plans."
|
||||||
|
>
|
||||||
|
<SiteHeader />
|
||||||
|
<main>
|
||||||
|
<section class="relative overflow-hidden border-b border-border">
|
||||||
|
<TopographicBg opacity={0.12} lines={8} />
|
||||||
|
<div class="relative max-w-content mx-auto px-6 pt-20 pb-12">
|
||||||
|
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
||||||
|
<h1 class="text-display font-bold text-text mb-6">Priced so engineers can say yes.</h1>
|
||||||
|
<p class="text-lg text-text-muted max-w-prose">
|
||||||
|
Start free for 14 days. No credit card required. Move up when you need more apps, longer retention, or enterprise features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="max-w-content mx-auto px-6 py-16">
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{tiers.map((tier) => (
|
||||||
|
<div class={`rounded-lg border bg-bg-elevated p-7 flex flex-col ${tier.highlight ? 'border-accent' : 'border-border'}`}>
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="font-mono text-xs tracking-wider text-text-muted mb-3">{tier.name.toUpperCase()}</div>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-3xl font-bold text-text">{tier.price}</span>
|
||||||
|
<span class="text-sm text-text-muted">{tier.priceNote}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="text-sm space-y-2 mb-6 text-text-muted flex-grow">
|
||||||
|
<li><span class="text-text">{tier.envs}</span></li>
|
||||||
|
<li><span class="text-text">{tier.apps}</span></li>
|
||||||
|
<li><span class="text-text">{tier.retention}</span></li>
|
||||||
|
<li class="pt-3 border-t border-border mt-3"></li>
|
||||||
|
{tier.features.map((f) => <li>• {f}</li>)}
|
||||||
|
</ul>
|
||||||
|
<a
|
||||||
|
href={tier.href}
|
||||||
|
class={`inline-flex items-center justify-center rounded px-4 py-2.5 text-sm font-semibold transition-colors
|
||||||
|
${tier.highlight
|
||||||
|
? 'bg-accent text-bg hover:bg-accent-muted'
|
||||||
|
: 'border border-border-strong text-text hover:border-accent hover:text-accent'}`}
|
||||||
|
>
|
||||||
|
{tier.cta}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p class="text-center text-text-faint text-sm mt-10">
|
||||||
|
Prices in EUR, excluding VAT. Billed monthly. Annual billing available for HIGH and BUSINESS — <a href={auth.salesMailto} class="text-accent hover:underline">talk to sales</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</BaseLayout>
|
||||||
100
src/pages/privacy.astro
Normal file
100
src/pages/privacy.astro
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import SiteHeader from '../components/SiteHeader.astro';
|
||||||
|
import SiteFooter from '../components/SiteFooter.astro';
|
||||||
|
|
||||||
|
const operatorContact = '<TBD: controller contact email (same as imprint)>';
|
||||||
|
const lastUpdated = '2026-04-24';
|
||||||
|
---
|
||||||
|
<BaseLayout
|
||||||
|
title="Privacy Policy — Cameleer"
|
||||||
|
description="Privacy policy for www.cameleer.io — what personal data we process (and don't), legal basis, and your rights under GDPR."
|
||||||
|
>
|
||||||
|
<SiteHeader />
|
||||||
|
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
||||||
|
<h1 class="text-hero font-bold text-text mb-2">Privacy Policy</h1>
|
||||||
|
<p class="text-text-faint text-sm mb-10">Last updated: {lastUpdated}</p>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">1. Overview</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed">
|
||||||
|
This policy describes what personal data is processed when you visit <span class="font-mono text-accent">www.cameleer.io</span>. Our goal is to collect as little data as technically possible.
|
||||||
|
<strong class="text-text">We do not set cookies. We do not run analytics scripts. We have no forms on this site.</strong>
|
||||||
|
If and when that changes, this policy will be updated and the change noted in the "Last updated" date above.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">2. Controller</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed">
|
||||||
|
The data controller responsible for processing on this site is the operator listed in our
|
||||||
|
<a href="/imprint" class="text-accent hover:underline">imprint</a>.
|
||||||
|
Contact for privacy matters: <span class="font-mono text-accent">{operatorContact}</span>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">3. Server access logs</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed mb-3">
|
||||||
|
When you access this site, our hosting provider (Hetzner Online GmbH, Germany) automatically records standard access log data in order to operate and secure the service:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside text-text-muted space-y-1 ml-2">
|
||||||
|
<li>IP address</li>
|
||||||
|
<li>Date and time of the request</li>
|
||||||
|
<li>HTTP method, requested path, and response status</li>
|
||||||
|
<li>User-agent string and referrer</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-text-muted leading-relaxed mt-3">
|
||||||
|
Legal basis: Art. 6(1)(f) GDPR (legitimate interest in operating and securing the service). Logs are retained for the duration applied by our hosting provider and are not combined with other data sources.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">4. Content delivery via Cloudflare</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed">
|
||||||
|
This site is delivered through Cloudflare, Inc. (101 Townsend St, San Francisco, CA 94107, USA). Cloudflare inspects incoming traffic for security and performance purposes (DDoS protection, WAF, caching). Processing is governed by a Data Processing Agreement and the EU Standard Contractual Clauses.
|
||||||
|
Legal basis: Art. 6(1)(f) GDPR (legitimate interest in availability and security).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">5. Cookies</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed">
|
||||||
|
This site sets no cookies and uses no browser storage of any kind. No consent banner is required because no consent-requiring technology is in use.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">6. External links</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed">
|
||||||
|
Sign-in and sign-up links on this site navigate you to <span class="font-mono text-accent">auth.cameleer.io</span> (Logto identity service) and subsequently <span class="font-mono text-accent">platform.cameleer.io</span>. Those services have their own privacy policies, which apply from the moment you arrive there.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">7. Your rights</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed mb-3">
|
||||||
|
Under the GDPR, you have the right to:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside text-text-muted space-y-1 ml-2">
|
||||||
|
<li>request access to personal data we process about you (Art. 15)</li>
|
||||||
|
<li>request rectification of inaccurate data (Art. 16)</li>
|
||||||
|
<li>request erasure of your data (Art. 17)</li>
|
||||||
|
<li>request restriction of processing (Art. 18)</li>
|
||||||
|
<li>object to processing based on legitimate interest (Art. 21)</li>
|
||||||
|
<li>lodge a complaint with a supervisory authority (Art. 77)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-text-muted leading-relaxed mt-3">
|
||||||
|
Contact us at <span class="font-mono text-accent">{operatorContact}</span> to exercise any of these rights.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-bold text-text mb-3">8. Changes to this policy</h2>
|
||||||
|
<p class="text-text-muted leading-relaxed">
|
||||||
|
We may update this policy as our data processing changes (for example, if we later add analytics). The "Last updated" date at the top of this page reflects the most recent revision.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</BaseLayout>
|
||||||
34
src/styles/global.css
Normal file
34
src/styles/global.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
@import '@fontsource/dm-sans/400.css';
|
||||||
|
@import '@fontsource/dm-sans/500.css';
|
||||||
|
@import '@fontsource/dm-sans/600.css';
|
||||||
|
@import '@fontsource/dm-sans/700.css';
|
||||||
|
@import '@fontsource/jetbrains-mono/400.css';
|
||||||
|
@import '@fontsource/jetbrains-mono/700.css';
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply bg-bg text-text font-sans;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply min-h-screen;
|
||||||
|
}
|
||||||
|
code, kbd, samp, pre {
|
||||||
|
@apply font-mono;
|
||||||
|
}
|
||||||
|
::selection {
|
||||||
|
@apply bg-accent text-bg;
|
||||||
|
}
|
||||||
|
:focus-visible {
|
||||||
|
@apply outline outline-2 outline-offset-2 outline-accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
tailwind.config.mjs
Normal file
46
tailwind.config.mjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: {
|
||||||
|
DEFAULT: '#060a13',
|
||||||
|
elevated: '#0c111a',
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
DEFAULT: '#1e2535',
|
||||||
|
strong: '#2a3242',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: '#f0b429',
|
||||||
|
muted: '#c89321',
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
DEFAULT: '#5cc8ff',
|
||||||
|
muted: '#3a9dd1',
|
||||||
|
},
|
||||||
|
rose: '#f43f5e',
|
||||||
|
green: '#10b981',
|
||||||
|
text: {
|
||||||
|
DEFAULT: '#e8eaed',
|
||||||
|
muted: '#9aa3b2',
|
||||||
|
faint: '#6b7280',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"DM Sans"', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
'display': ['clamp(2.5rem, 5vw, 4.5rem)', { lineHeight: '1.05', letterSpacing: '-0.02em' }],
|
||||||
|
'hero': ['clamp(1.75rem, 3vw, 2.75rem)', { lineHeight: '1.15', letterSpacing: '-0.015em' }],
|
||||||
|
},
|
||||||
|
maxWidth: {
|
||||||
|
content: '72rem',
|
||||||
|
prose: '42rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
5
tsconfig.json
Normal file
5
tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": ["src/**/*", "astro.config.mjs", "tailwind.config.mjs"],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'astro:middleware': path.resolve('./src/__mocks__/astro-middleware.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user