Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
worker/worker-configuration.d.ts linguist-generated=true
27 changes: 20 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# WithMarfa website

The source of the public site at `withmarfa.com`: Astro, built to static files and served as a Cloudflare Worker with static assets only. GitHub Actions builds every pull request and deploys `main`.
The source of the public site at `withmarfa.com`: Astro, built to static files and served as a Cloudflare Worker. Every page is a static asset; the Worker's code runs only for `/api/*`, which today is the sign-up form. GitHub Actions builds every pull request and deploys `main`.

## Conventions

Expand All @@ -14,21 +14,24 @@ The source of the public site at `withmarfa.com`: Astro, built to static files a

## Layout

- `src/pages/`: one file per page. `404.astro` is what Cloudflare serves for a missing path.
- `src/pages/`: one file per page. `404.astro` is what Cloudflare serves for a missing path. `sitemap.xml.ts` lists every page except those in its `UNLISTED` set.
- `src/layouts/Base.astro`: the document head, the header, the column and the footer, used by every page.
- `src/components/`: the header (the Docs pill, and the way home on every other page), the footer (About, Contact and GitHub), and the question that opens.
- `src/components/`: the header (the Marfa mark, which leads home, a GitHub icon and the Docs pill), the footer (About, the Contact card and Privacy), the question that opens (one at a time within a group), and the sign-up form.
- `src/styles/global.css`: the color tokens, shared with the docs site's canvas and text colors. Light and dark follow the system; there is no toggle.
- `src/lib/analytics.ts`: PostHog, loaded after the page.
- `public/`: copied as is. `_headers` sets the security headers and the Content Security Policy.
- `public/`: copied as is. `_headers` sets the security headers and the Content Security Policy. `og.png` is the link preview image, rendered from `scripts/og-image.html`.
- `worker/index.ts`: the Worker, which answers `POST /api/subscribe`. `worker/worker-configuration.d.ts` is generated by `pnpm types` from `wrangler.jsonc`; regenerate it whenever bindings change, or `pnpm check` fails.
- `migrations/`: the D1 schema for sign-ups.

## Commands

```bash
pnpm install
pnpm dev # http://localhost:4321
pnpm check # astro check, the type check
pnpm check # type checks for the site and the Worker
pnpm types # regenerate the Worker's binding types
pnpm build # static files in dist/
pnpm preview # build, then serve dist/ with Wrangler as Cloudflare will
pnpm preview # build, then serve dist/ and the Worker with Wrangler as Cloudflare will
```

## Deploying
Expand All @@ -39,6 +42,14 @@ pnpm preview # build, then serve dist/ with Wrangler as Cloudflare will
- A build without `PUBLIC_POSTHOG_KEY` sends nothing, which is what a fork or a local build gets.
- The fallback when the workflow cannot deploy is `pnpm run build && pnpm exec wrangler deploy` from a shell authenticated to the WithMarfa account.

## Sign-ups

- The form on About posts to `/api/subscribe`. The Worker checks that the request comes from this site, limits each IP address to five tries a minute, ignores bots that fill the hidden `website` field, and keeps the address, lowercased, with the time in the `signups` table of the D1 database `withmarfa-website` (EU jurisdiction). A repeat address changes nothing and gets the same reply, so the form never reveals who is on the list.
- With JavaScript the form shows the result in place; without it the Worker redirects to `/subscribed` or `/subscribe-failed`.
- Nothing logs an address. Invocation logs and traces are off, because they would keep each visitor's location and browser beside the time of their sign-up; only the Worker's own log lines are kept. To read the list, from a shell authenticated to the WithMarfa account: `pnpm exec wrangler d1 execute withmarfa-website --remote --command "SELECT email, created_at FROM signups ORDER BY created_at"`. To remove someone, `DELETE FROM signups WHERE email = '…'`.
- A schema change is a new file in `migrations/`, applied with `pnpm exec wrangler d1 migrations apply withmarfa-website --remote` before the code that needs it is merged. The deploy does not apply migrations.
- `src/pages/privacy.astro` says what is kept and why. Change it in the same pull request as anything that changes what the site collects.

## Analytics

- Page views, page leaves and unhandled errors go to the PostHog project the docs and status sites use, with `site: 'website'` on every event. Nothing else is captured.
Expand All @@ -52,6 +63,8 @@ Report only a problem you can show: name the page, input or step, and what goes
- **Untrue about Marfa.** Flag a claim about what Marfa does, where it runs or what it costs that withmarfa/marfa or docs.marfa.so contradicts. Never say Marfa Cloud is free.
- **Deploy weakened.** Flag a `paths` filter on `ci.yml`, a renamed `Build and check` job, or a deploy that no longer checks the live site.
- **Analytics grown.** Flag an option in `src/lib/analytics.ts` that turns on autocapture, replay, surveys, flags, cookies or storage, or a static import of `posthog-js`.
- **Policy broken.** Flag an inline script or style, or a new third-party origin, that `public/_headers` does not allow.
- **Policy broken.** Flag an inline script or style, or a new third-party origin, that `public/_headers` does not allow. `vite.build.assetsInlineLimit: 0` in `astro.config.mjs` keeps Astro from inlining small scripts.
- **Sign-ups exposed.** Flag code that logs an email address, returns one, tells a visitor whether an address is already on the list, or adds a route that reads the list.
- **Privacy page untrue.** Flag a change to what the site collects or where it keeps it that leaves `privacy.astro` unchanged.
- **Private detail committed.** Flag a token, key, account ID, internal hostname or personal path in a tracked file.
- **Stale instructions.** Flag a change that leaves this file, the README or a comment untrue, and fix it in the same pull request.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ pnpm install
pnpm dev
```

The site runs at `http://localhost:4321`. To check types and build the static files:
The site runs at `http://localhost:4321`. The sign-up form needs the Worker as well; `pnpm preview` builds the site and serves both with Wrangler, after `pnpm exec wrangler d1 migrations apply withmarfa-website --local` has created the local database.

To check types and build the static files:

```bash
pnpm check
Expand Down
11 changes: 11 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://withmarfa.com',
output: 'static',
// Astro 7's default ('jsx') drops a line break before an inline element,
// so prose wrapped in the source loses its space ("The<a>About</a>").
// `true` compresses without changing what renders.
compressHTML: true,
trailingSlash: 'never',
build: {
// `about.html` rather than `about/index.html`, so Cloudflare serves
Expand All @@ -13,4 +17,11 @@ export default defineConfig({
// in `public/_headers` needs no 'unsafe-inline'.
inlineStylesheets: 'never',
},
vite: {
build: {
// Astro inlines a script smaller than this limit, and the Content
// Security Policy blocks inline scripts, so every script stays a file.
assetsInlineLimit: 0,
},
},
});
5 changes: 5 additions & 0 deletions migrations/0001_signups.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- One row for each address that asked for updates about Marfa.
CREATE TABLE signups (
email TEXT PRIMARY KEY,
created_at TEXT NOT NULL
);
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
},
"scripts": {
"dev": "astro dev",
"check": "astro check",
"check": "astro check && wrangler types worker/worker-configuration.d.ts --check && tsc -p worker",
"build": "astro build",
"types": "wrangler types worker/worker-configuration.d.ts",
"preview": "pnpm run build && wrangler dev"
},
"dependencies": {
Expand Down
10 changes: 10 additions & 0 deletions public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
User-agent: *
Allow: /

Sitemap: https://withmarfa.com/sitemap.xml
15 changes: 15 additions & 0 deletions scripts/og-image.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!--
The link preview image, public/og.png. Render it at 1200×630 with Chrome:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --hide-scrollbars \
--force-device-scale-factor=1 --window-size=1200,630 --screenshot=public/og.png scripts/og-image.html
-->
<!doctype html><html><head><meta charset="utf-8"><style>
html,body{margin:0;width:1200px;height:630px;background:#fdfdfc;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display',system-ui,sans-serif;-webkit-font-smoothing:antialiased;color:#0a0a0a}
.wrap{position:absolute;inset:0;padding:96px 104px;display:flex;flex-direction:column}
svg{display:block}
h1{margin:auto 0 0;font-size:84px;line-height:1;font-weight:600;letter-spacing:-0.03em}
p{margin:22px 0 0;font-size:38px;line-height:1.25;color:#737373;letter-spacing:-0.01em}
</style></head><body><div class="wrap">
<svg viewBox="11 4 360 360" width="72" height="72"><clipPath id="t"><rect x="11" y="4" width="360" height="360" rx="120"/></clipPath><g clip-path="url(#t)"><rect x="11" y="4" width="360" height="360" fill="#0a0a0a"/><path fill="#fdfdfc" d="M489.455 -105C516.618 -105 537.669 -97.5247 552.609 -82.574C566.87 -67.6233 574 -46.5565 574 -19.3735L574 546.373C574 574.236 566.191 595.303 550.572 609.574C534.953 624.525 512.883 632 484.362 632C460.594 632 441.919 627.243 428.338 617.729C414.077 608.894 401.175 593.944 389.63 572.877L170.628 179.402H206.279L-11.7048 572.877C-23.9282 593.944 -36.8307 608.894 -50.4123 617.729C-64.6729 627.243 -83.6871 632 -107.455 632C-134.618 632 -155.33 624.525 -169.59 609.574C-184.53 595.303 -192 574.236 -192 546.373L-192 -19.3735C-192 -46.5565 -184.87 -67.6233 -170.609 -82.574C-156.348 -97.5247 -135.297 -105 -107.455 -105C-80.2917 -105 -59.2403 -97.5247 -44.3006 -82.574C-30.0399 -67.6233 -22.9096 -46.5565 -22.9096 -19.3735L-22.9096 331.288H-44.3006L119.697 45.8658C129.204 30.9151 139.39 19.7022 150.255 12.2268C160.441 4.75149 174.363 1.01385 192.019 1.01385C208.996 1.01385 222.917 4.75149 233.782 12.2268C244.647 19.7022 254.494 30.9151 263.322 45.8658L428.338 332.307H404.91L404.91 -19.3735C404.91 -46.5565 412.04 -67.6233 426.301 -82.574C440.561 -97.5247 461.613 -105 489.455 -105Z"/></g></svg>
<h1>Marfa</h1><p>A shared data layer for your apps</p>
</div></body></html>
151 changes: 135 additions & 16 deletions src/components/Footer.astro
Original file line number Diff line number Diff line change
@@ -1,44 +1,163 @@
---
const path = Astro.url.pathname.replace(/(\/index)?\.html$/, '') || '/';
import { pagePath } from '../lib/path';
/**
* The footer, and the Contact card it opens. The card shows the address before
* anything opens a mail app, and offers to copy it instead.
*/
const path = pagePath(Astro.url);
const EMAIL = 'hello@withmarfa.com';
---

<footer class="col">
<span>© 2026 WithMarfa</span>
<nav aria-label="More">
<nav aria-label="Site">
<a href="/about" aria-current={path === '/about' ? 'page' : undefined}>About</a>
<span class="bar" aria-hidden="true">|</span>
<a href="mailto:hello@withmarfa.com">Contact</a>
<span class="bar" aria-hidden="true">|</span>
<a href="https://github.com/withmarfa">GitHub</a>
<button type="button" popovertarget="contact">Contact</button>
<a href="/privacy" aria-current={path === '/privacy' ? 'page' : undefined}>Privacy</a>
</nav>
</footer>

<div id="contact" popover role="dialog" aria-labelledby="contact-title">
<p id="contact-title" class="title">Get in touch</p>
<p class="address">{EMAIL}</p>
<div class="actions">
<button type="button" class="secondary" data-copy={EMAIL}>Copy address</button>
<a class="primary" href={`mailto:${EMAIL}`}>Open Mail</a>
</div>
</div>

<script>
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-copy]')) {
const label = button.textContent;
button.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(button.dataset.copy!);
button.textContent = 'Copied';
} catch {
// Clipboard access can be refused; the address stays on screen to select.
button.textContent = 'Couldn’t copy';
}
setTimeout(() => (button.textContent = label), 1800);
});
}
</script>

<style>
footer {
margin-top: auto;
padding: 28px 0 max(32px, env(safe-area-inset-bottom));
padding: 20px 0 max(24px, env(safe-area-inset-bottom));
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-size: 13px;
color: var(--muted);
}

a {
nav {
display: flex;
gap: 18px;
}

nav a,
nav button {
padding: 0;
border: 0;
background: none;
color: var(--muted);
font: inherit;
text-decoration: none;
cursor: pointer;
transition: color 0.15s ease;
}

a:hover,
a[aria-current='page'] {
nav a:hover,
nav button:hover,
nav a[aria-current='page'] {
color: var(--text);
}

/* The card sits in the middle of the window, over a soft veil. */
[popover] {
width: min(320px, calc(100vw - 32px));
box-sizing: border-box;
margin: auto;
padding: 20px;
border: 1px solid var(--rule);
border-radius: 16px;
background: var(--bg);
color: var(--text);
box-shadow: 0 12px 40px color-mix(in srgb, #000 14%, transparent);
opacity: 0;
transform: translateY(6px) scale(0.98);
transition:
opacity 0.2s ease,
transform 0.2s ease,
overlay 0.2s allow-discrete,
display 0.2s allow-discrete;
}

[popover]:popover-open {
opacity: 1;
transform: none;
}

@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
}

[popover]::backdrop {
background: color-mix(in srgb, #000 18%, transparent);
}

.title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}

.address {
margin: 0 0 16px;
color: var(--secondary);
font-size: 15px;
user-select: all;
}

.actions {
display: flex;
gap: 8px;
}

.actions > * {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 12px;
border: 0;
border-radius: 10px;
font: inherit;
font-size: 14px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease;
}

.actions > *:hover {
opacity: 0.85;
}

.secondary {
background: color-mix(in srgb, var(--text) 7%, transparent);
color: var(--text);
}

.bar {
margin: 0 10px;
color: var(--line);
.primary {
background: var(--text);
color: var(--bg);
}
</style>
Loading
Loading