diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b73854c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +worker/worker-configuration.d.ts linguist-generated=true diff --git a/AGENTS.md b/AGENTS.md index 4c37a66..ae70dae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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. @@ -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. diff --git a/README.md b/README.md index aa3fc1f..65493a8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/astro.config.mjs b/astro.config.mjs index 5d7c45a..446d20c 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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 ("TheAbout"). + // `true` compresses without changing what renders. + compressHTML: true, trailingSlash: 'never', build: { // `about.html` rather than `about/index.html`, so Cloudflare serves @@ -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, + }, + }, }); diff --git a/migrations/0001_signups.sql b/migrations/0001_signups.sql new file mode 100644 index 0000000..0de2099 --- /dev/null +++ b/migrations/0001_signups.sql @@ -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 +); diff --git a/package.json b/package.json index e9c129f..137477b 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..ea8fd4c --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,10 @@ + + Marfa + + + + + + + + diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000..f1d08e2 Binary files /dev/null and b/public/og.png differ diff --git a/public/robots.txt b/public/robots.txt index c2a49f4..d76985a 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,2 +1,4 @@ User-agent: * Allow: / + +Sitemap: https://withmarfa.com/sitemap.xml diff --git a/scripts/og-image.html b/scripts/og-image.html new file mode 100644 index 0000000..a0c3c7c --- /dev/null +++ b/scripts/og-image.html @@ -0,0 +1,15 @@ + +
+ +

Marfa

A shared data layer for your apps

+
diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 737965c..9bde17f 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -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'; --- + + + + diff --git a/src/components/Header.astro b/src/components/Header.astro index c164dfb..6309d8d 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -1,16 +1,38 @@ --- -/** The docs, and on every page but home, the way back home. */ -const path = Astro.url.pathname.replace(/(\/index)?\.html$/, '') || '/'; +import { pagePath } from '../lib/path'; +/** + * The Marfa mark, which leads home, and the two ways out: GitHub and the docs. + * The mark is the docs' favicon.svg, drawn in the page's colors. + */ +const path = pagePath(Astro.url); ---
- {path === '/' ? : Marfa} - - Docs -
diff --git a/src/components/Subscribe.astro b/src/components/Subscribe.astro new file mode 100644 index 0000000..c24ee8a --- /dev/null +++ b/src/components/Subscribe.astro @@ -0,0 +1,171 @@ +--- +/** + * Signs someone up for updates. Without JavaScript the form posts and the + * Worker redirects to /subscribed or /subscribe-failed; with it, the result shows in place. The + * `website` field is left empty by people and filled by bots, which the Worker + * then ignores. + */ +--- + +
+
+ + + + +
+

+ Occasional updates, nothing else. Unsubscribe any time. Privacy +

+
+ + + + diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 654ad90..f83d8fa 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -2,17 +2,18 @@ import '../styles/global.css'; import Header from '../components/Header.astro'; import Footer from '../components/Footer.astro'; +import { pagePath } from '../lib/path'; interface Props { title: string; description: string; - /** Off for the 404 page, which has no address of its own. */ + /** Off for pages search engines should skip, such as the 404 page. */ indexable?: boolean; } const { title, description, indexable = true } = Astro.props; -// Pages build to `about.html`, which Cloudflare serves at `/about`. -const canonical = new URL(Astro.url.pathname.replace(/(\/index)?\.html$/, '') || '/', Astro.site); +const canonical = new URL(pagePath(Astro.url), Astro.site); +const image = new URL('/og.png', Astro.site).href; --- @@ -25,14 +26,21 @@ const canonical = new URL(Astro.url.pathname.replace(/(\/index)?\.html$/, '') || {title} - {indexable && } - + {indexable ? : } + + {indexable && } + + + + + +
diff --git a/src/lib/path.ts b/src/lib/path.ts new file mode 100644 index 0000000..9fb2cf0 --- /dev/null +++ b/src/lib/path.ts @@ -0,0 +1,8 @@ +/** + * A page's path as people see it. Pages build to `about.html`, which + * Cloudflare serves at `/about`, so the build-time path loses `.html` and + * `index`. + */ +export function pagePath(url: URL): string { + return url.pathname.replace(/(\/index)?\.html$/, '') || '/'; +} diff --git a/src/pages/about.astro b/src/pages/about.astro index c106ca2..f66caa2 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -1,34 +1,43 @@ --- import Base from '../layouts/Base.astro'; +import Subscribe from '../components/Subscribe.astro'; /** What's next. `now` marks the step in progress, shown in bold. */ const next = [ - { when: 'Early October', what: 'Development versions are public, with the code.' }, - { when: 'Late October', what: 'Marfa 0.1, the first semi-stable release.', now: true }, - { when: 'Early November', what: 'The Mac and iOS apps on TestFlight for an early group.' }, - { when: 'Late November', what: 'Public TestFlight for the Mac and iOS apps.' }, - { when: 'December', what: 'More apps and connectors, and Marfa Cloud in testing.' }, + { when: 'Early October', what: 'Development builds, open source' }, + { when: 'Late October', what: 'Marfa 0.1, the first semi-stable release', now: true }, + { when: 'Early November', what: 'The Mac and iOS apps with early testers' }, + { when: 'Late November', what: 'Public TestFlight, Marfa Cloud in testing, and the full website' }, ]; ---

About

-

Marfa started as my own way of managing my data: one place that the apps and tools I use could all work from.

- Over this year it went through several rounds of building and testing. It began as a hosted service, and this - autumn I rebuilt it as something you run yourself, with a Rust core under its command-line tool and native apps. - This is the version I’m building on now. + Marfa grew out of a frustration. After trying more apps than I can count, every switch meant leaving something + behind. It also draws on experience with customer data platforms, where one well-modelled source of data is the + whole point.

- It’s a personal project, not a business. I maintain it, I’ll keep maintaining it, and I’ll keep building apps on - it. It’s open source so that anyone can run it, build on it or take it somewhere new. + That matters more now that agents can build an app in an afternoon. Apps can come and go; the data underneath + should last, and make sense to whatever reads it next. +

+

+ Marfa has been rebuilt more than once this year. It began as a hosted service that tried to be compatible with + everything, which pulled it away from what mattered most: a sound data model and a server you can rely on. So it + became something you run yourself, with a Rust core under its command-line tool and native apps. Marfa Cloud will + follow as an optional relay for reaching it from anywhere, without holding your data. +

+

+ It’s a personal project, not a business. It ships when it works, not before. Marfa is free and open source, so anyone + can run it, build on it or take it somewhere new.

What’s next

- + -

Get in touch

-

For questions, ideas or something you’ve built on Marfa, email hello@withmarfa.com.

+

Follow along

+

+ Marfa is being shaped with a small group of early testers. Leave your email to hear as the apps open up and new + things ship. +

+ diff --git a/src/pages/index.astro b/src/pages/index.astro index 3c895a0..3ef84c2 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,44 +1,50 @@ --- import Base from '../layouts/Base.astro'; import Question from '../components/Question.astro'; + +/** Gives Google the site's name for its results. */ +const site = { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'Marfa', + alternateName: 'WithMarfa', + url: 'https://withmarfa.com/', +}; --- +