@saastemly/voidcommerce 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +271 -0
  2. package/bin/vc +2 -0
  3. package/dist/catalog.d.ts +69 -0
  4. package/dist/catalog.js +34 -0
  5. package/dist/cli.d.ts +24 -0
  6. package/dist/cli.js +544 -0
  7. package/dist/deploy/cloudflare.d.ts +25 -0
  8. package/dist/deploy/index.d.ts +16 -0
  9. package/dist/deploy/jsonc.d.ts +8 -0
  10. package/dist/deploy/preflight.d.ts +29 -0
  11. package/dist/deploy/wrangler.d.ts +37 -0
  12. package/dist/dist.d.ts +22 -0
  13. package/dist/generate/auth.d.ts +2 -0
  14. package/dist/generate/ci.d.ts +24 -0
  15. package/dist/generate/env.d.ts +13 -0
  16. package/dist/generate/frontend.d.ts +47 -0
  17. package/dist/generate/index.d.ts +28 -0
  18. package/dist/generate/requirements.d.ts +13 -0
  19. package/dist/generate/strict.d.ts +72 -0
  20. package/dist/generate/support.d.ts +23 -0
  21. package/dist/help.d.ts +31 -0
  22. package/dist/import.d.ts +2 -0
  23. package/dist/index-s7sq41qs.js +590 -0
  24. package/dist/index-ssv3a6wc.js +172 -0
  25. package/dist/index-wzy1xtr1.js +3155 -0
  26. package/dist/index.d.ts +24 -0
  27. package/dist/index.js +190 -0
  28. package/dist/init.d.ts +1 -0
  29. package/dist/manifest.d.ts +131 -0
  30. package/dist/manifest.js +41 -0
  31. package/dist/project.d.ts +20 -0
  32. package/dist/regenerate.d.ts +9 -0
  33. package/dist/scripts.d.ts +12 -0
  34. package/dist/void.d.ts +30 -0
  35. package/dist/wizard.d.ts +7 -0
  36. package/package.json +50 -0
  37. package/src/catalog.ts +673 -0
  38. package/src/cli.ts +78 -0
  39. package/src/deploy/cloudflare.ts +166 -0
  40. package/src/deploy/index.ts +101 -0
  41. package/src/deploy/jsonc.ts +148 -0
  42. package/src/deploy/preflight.ts +137 -0
  43. package/src/deploy/wrangler.ts +111 -0
  44. package/src/dist.ts +157 -0
  45. package/src/generate/auth.ts +386 -0
  46. package/src/generate/ci.ts +208 -0
  47. package/src/generate/env.ts +164 -0
  48. package/src/generate/frontend.ts +275 -0
  49. package/src/generate/index.ts +390 -0
  50. package/src/generate/requirements.ts +48 -0
  51. package/src/generate/strict.ts +692 -0
  52. package/src/generate/support.ts +252 -0
  53. package/src/help.ts +172 -0
  54. package/src/import.ts +237 -0
  55. package/src/index.ts +37 -0
  56. package/src/init.ts +187 -0
  57. package/src/manifest.ts +303 -0
  58. package/src/project.ts +63 -0
  59. package/src/regenerate.ts +51 -0
  60. package/src/scripts.ts +53 -0
  61. package/src/void.ts +115 -0
  62. package/src/wizard.ts +234 -0
@@ -0,0 +1,208 @@
1
+ import { DIST_BRANCH, DIST_DIR } from "../dist";
2
+ import type { Manifest } from "../manifest";
3
+
4
+ /**
5
+ * The workflow that turns a push into a deploy.
6
+ *
7
+ * ── Why a branch and not a build ─────────────────────────────────────────
8
+ *
9
+ * Cloudflare's Workers Builds watches a repository and builds what it finds.
10
+ * A strict repository has no app in it — `.vc/app` is generated and
11
+ * gitignored — and Cloudflare documents nothing about a build command that
12
+ * writes the source it then builds. The install step's ordering against a
13
+ * generated `package.json` is undocumented, which is not a thing to guess at
14
+ * in the path that puts a shop on the internet.
15
+ *
16
+ * So the generator runs in GitHub Actions, where it is ordinary, and pushes
17
+ * the result to its own branch. `main` stays the manifest and the data;
18
+ * `void-dist` is a plain Void app with a committed `package.json`, lockfile
19
+ * and `wrangler.jsonc` that anything can build from a cold checkout.
20
+ * Cloudflare is pointed at that branch and needs to know nothing about
21
+ * voidcommerce — which also means the deploy path is one a person can run by
22
+ * hand when CI is not the answer.
23
+ */
24
+ export function renderDistWorkflow(manifest: Manifest): string {
25
+ const worker = manifest.shop.domain.split(".")[0];
26
+ return `name: Build the deployable app
27
+
28
+ # Generated by \`vc init\` from voidcommerce.json.
29
+ #
30
+ # Every push to main regenerates the Void app from the manifest and force-pushes
31
+ # it to \`${DIST_BRANCH}\`. Cloudflare's Workers Builds watches THAT branch and
32
+ # deploys it, so a push here is a deploy — and no Cloudflare credential is
33
+ # stored in GitHub.
34
+ #
35
+ # The Cloudflare side is configured once, in the dashboard, for the Worker
36
+ # named \`${worker}\` (Settings → Builds). DEPLOY.md has the settings and the
37
+ # one non-obvious part: the build's API token needs D1:Edit, which the token
38
+ # Cloudflare generates for you does not have.
39
+ on:
40
+ push:
41
+ branches: [main, master]
42
+ workflow_dispatch:
43
+
44
+ # A later push wins: an older tree must never overwrite a newer one.
45
+ concurrency:
46
+ group: ${DIST_BRANCH}-\${{ github.repository }}
47
+ cancel-in-progress: true
48
+
49
+ # contents: write is what lets GITHUB_TOKEN force-push the branch. Nothing else.
50
+ permissions:
51
+ contents: write
52
+
53
+ jobs:
54
+ build:
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v6
58
+ - uses: oven-sh/setup-bun@v2
59
+
60
+ - run: bun install --frozen-lockfile
61
+
62
+ # Regenerates the app from voidcommerce.json, runs void's own prepare and
63
+ # migration generation, then resolves the symlinks into a tree that stands
64
+ # on its own.
65
+ - run: bunx vc dist
66
+
67
+ # A migration generated here and not committed would be applied under a
68
+ # new name on the next run, against tables that already exist. The
69
+ # migrations belong to the repository, so this fails rather than
70
+ # inventing one.
71
+ - name: Refuse to ship a migration that is not committed
72
+ run: |
73
+ set -euo pipefail
74
+ if [ -n "$(git status --porcelain migrations/)" ]; then
75
+ echo "::error::vc generate wrote a migration that is not committed."
76
+ git status --porcelain migrations/
77
+ echo "Run 'vc generate' locally, review it, and commit it."
78
+ exit 1
79
+ fi
80
+
81
+ - name: Publish to ${DIST_BRANCH}
82
+ env:
83
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
84
+ run: |
85
+ set -euo pipefail
86
+ cd ${DIST_DIR}
87
+ # A fresh single-commit history each run: the branch is build output,
88
+ # so it is replaced rather than accumulating commits.
89
+ git init -q -b ${DIST_BRANCH}
90
+ git config user.name "github-actions[bot]"
91
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
92
+ git add -A
93
+ git commit -q -m "\${{ github.sha }} — ${manifest.shop.domain}"
94
+ git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${DIST_BRANCH}
95
+ `;
96
+ }
97
+
98
+ /** What a person still has to do once, and why each thing cannot be done for them. */
99
+ export function renderDeployReadme(manifest: Manifest, zone: string, hosts: string[]): string {
100
+ const worker = manifest.shop.domain.split(".")[0];
101
+ return `# Going live
102
+
103
+ \`${manifest.shop.domain}\` on Cloudflare Workers. Generated by \`vc init\`;
104
+ regenerate with \`vc generate\`.
105
+
106
+ ## The loop, once it is set up
107
+
108
+ Push to \`main\`. GitHub Actions regenerates the app from \`voidcommerce.json\`
109
+ and force-pushes it to \`${DIST_BRANCH}\`; Cloudflare's Workers Builds builds that
110
+ branch and deploys it. Nothing else runs.
111
+
112
+ Your secrets survive every deploy — \`wrangler deploy\` never deletes a secret.
113
+ Plaintext \`vars\` are replaced from \`.env.production\` on each deploy, which is
114
+ why no credential is allowed in that file.
115
+
116
+ ## Once, before the first push
117
+
118
+ Each of these either creates something in your Cloudflare account or holds a
119
+ credential, so none of them can live in a repository.
120
+
121
+ ### 1. The zone
122
+
123
+ \`${zone}\` must be on Cloudflare. A Worker custom domain is a record Cloudflare
124
+ creates in its own zone, so a domain hosted anywhere else cannot have one. The
125
+ worker answers on ${hosts.map((h) => `\`${h}\``).join(" and ")}.
126
+
127
+ ### 2. The database, the queue, and the account id
128
+
129
+ \`\`\`sh
130
+ vc deploy --cloudflare --provision
131
+ \`\`\`
132
+
133
+ Idempotent: it creates nothing that already exists. It pins your account id,
134
+ creates the D1 database and the queue, and records both in \`wrangler.jsonc\`
135
+ and \`voidcommerce.json\` so every later generate carries the real ids.
136
+ **Commit that change.**
137
+
138
+ ### 3. The secrets
139
+
140
+ \`\`\`sh
141
+ vc preflight --cloudflare
142
+ \`\`\`
143
+
144
+ lists every required key, says what breaks without it, and prints the
145
+ \`wrangler secret put\` line. On a Worker that has never been deployed, setting
146
+ the first secret is what creates it.
147
+
148
+ ### 4. An API token the build can use
149
+
150
+ This is the one non-obvious step. Cloudflare generates an API token for
151
+ Workers Builds automatically, and **that token has no D1 permission** — its
152
+ scopes are Workers Scripts, KV, R2, Workers Routes and account/user reads. The
153
+ deploy command below applies database migrations, so it needs more.
154
+
155
+ Create a token at **My Profile → API Tokens** with:
156
+
157
+ | scope | permission |
158
+ |---|---|
159
+ | Account | Workers Scripts: Edit |
160
+ | Account | D1: Edit |
161
+ | Account | Workers KV Storage: Edit, Workers R2 Storage: Edit |
162
+ | Account | Queues: Edit |
163
+ | Account | Account Settings: Read |
164
+ | Zone | Workers Routes: Edit (${zone}) |
165
+ | User | User Details: Read, Memberships: Read |
166
+
167
+ Then select it in the build settings below. Use the same token for every
168
+ deploy of this Worker; permissions are whatever that token has.
169
+
170
+ ### 5. Connect the repository
171
+
172
+ In the Cloudflare dashboard, on the Worker named \`${worker}\`, under
173
+ **Settings → Builds → Connect**. The Worker's name must equal the \`name\` in
174
+ the \`wrangler.jsonc\` at the root directory, or the build fails.
175
+
176
+ | setting | value |
177
+ |---|---|
178
+ | branch | \`${DIST_BRANCH}\` |
179
+ | root directory | \`/\` |
180
+ | build command | \`bun install && bunx void prepare && bunx vp build\` |
181
+ | deploy command | \`bunx wrangler d1 migrations apply DB --remote && bunx wrangler deploy -c dist/ssr/wrangler.json\` |
182
+ | API token | the one from step 4 |
183
+
184
+ The migration command names the **binding** (\`DB\`), not the database, so it
185
+ still points at the right database if the name ever differs.
186
+
187
+ Cloudflare's build image has Bun; pin its version with a \`BUN_VERSION\` build
188
+ variable if you ever need to. The free plan allows 3,000 build minutes a month
189
+ and one build at a time.
190
+
191
+ ### 6. The catalogue
192
+
193
+ \`vc import\` pushes \`data/\` into the running shop. It is an upsert, so it is
194
+ safe on every deploy and costs one pass when nothing changed.
195
+
196
+ ## Without Cloudflare's build
197
+
198
+ The same thing by hand, from a checkout of either branch:
199
+
200
+ \`\`\`sh
201
+ vc deploy --cloudflare
202
+ \`\`\`
203
+
204
+ which preflights, builds, strips the baked development values out of the
205
+ worker's vars, applies the remote migrations, and deploys exactly what it
206
+ verified.
207
+ `;
208
+ }
@@ -0,0 +1,164 @@
1
+ import { type EnvKey } from "../catalog";
2
+ import { type Manifest, envKeysOf, has, hasFrontend, isApex, zone } from "../manifest";
3
+
4
+ /**
5
+ * `env.ts`, `.env.example` and `.env.production` — from the manifest.
6
+ *
7
+ * Every key is REQUIRED, with no default. A default is compiled into the
8
+ * worker's vars and shadows the real secret; Void's own deploy gate calls a
9
+ * required key absent from the vars "the only state a remote secret can
10
+ * discharge". Locally, `unset` is the one documented value that reads as
11
+ * absent, and preflight refuses it.
12
+ */
13
+
14
+ /** Keys every shop has, whatever else was chosen. */
15
+ const BASE: EnvKey[] = [
16
+ {
17
+ key: "SHOP_DOMAIN",
18
+ breaks: "everything derived from it is wrong at once — no public origin, the storefront rejected as untrusted, no DNS records",
19
+ plaintext: true,
20
+ },
21
+ {
22
+ key: "COMMERCE_CRON_SECRET",
23
+ breaks: "the scheduler cannot authenticate, so every background job stops",
24
+ where: "openssl rand -base64 32",
25
+ },
26
+ {
27
+ key: "COMMERCE_WEBHOOK_SECRET",
28
+ breaks: "payment webhooks cannot be verified, so no order ever becomes paid",
29
+ where: "the payment provider's dashboard",
30
+ },
31
+ {
32
+ key: "SHIPPING_DOMESTIC_MINOR",
33
+ breaks: "delivery is quoted at nothing, and every order loses the carrier cost",
34
+ plaintext: true,
35
+ dev: "4900",
36
+ where: "your carrier agreement, in minor units ex VAT",
37
+ },
38
+ {
39
+ key: "SHIPPING_FREE_FROM_MINOR",
40
+ breaks: "the free-delivery threshold is undefined",
41
+ plaintext: true,
42
+ dev: "100000",
43
+ },
44
+ ];
45
+
46
+ export function allEnvKeys(manifest: Manifest): EnvKey[] {
47
+ const keys = [...BASE, ...envKeysOf(manifest)];
48
+ // Only a shop that is not its zone's apex needs to name the zone: records
49
+ // go there, and the worker's custom domain needs that zone on Cloudflare.
50
+ if (!isApex(manifest)) {
51
+ keys.splice(1, 0, {
52
+ key: "SHOP_ZONE",
53
+ breaks: "DNS records are written to the wrong zone, or to none",
54
+ plaintext: true,
55
+ });
56
+ }
57
+ // Only a layout with a storefront has somewhere to point the records.
58
+ if (hasFrontend(manifest.layout) && manifest.shop.pagesHost) {
59
+ keys.splice(1, 0, {
60
+ key: "GITHUB_PAGES_HOST",
61
+ breaks: "the www record has nothing to point at",
62
+ plaintext: true,
63
+ });
64
+ }
65
+ return keys;
66
+ }
67
+
68
+ const NUMERIC = new Set(["SHIPPING_DOMESTIC_MINOR", "SHIPPING_FREE_FROM_MINOR"]);
69
+
70
+ export function renderEnvTs(manifest: Manifest): string {
71
+ const keys = allEnvKeys(manifest);
72
+ const lines = keys.map((key) => {
73
+ const helper = NUMERIC.has(key.key) ? "number()" : "string()";
74
+ const doc = [` /** ${key.breaks}${key.where ? ` — from: ${key.where}` : ""} */`];
75
+ return `${doc.join("\n")}\n ${key.key}: ${helper},`;
76
+ });
77
+ return `import { defineEnv, number, string } from "void/env";
78
+
79
+ /**
80
+ * Every env key the app reads. All of them are REQUIRED.
81
+ *
82
+ * Generated by \`vc init\` from voidcommerce.json — edit the manifest and
83
+ * regenerate rather than editing this by hand.
84
+ *
85
+ * Nothing is optional: an integration this shop supports is one the
86
+ * deployment sets up. No key has a default, because a default is compiled
87
+ * into the worker's vars and shadows the real secret. Locally, the literal
88
+ * value \`unset\` is the one documented way to say "I do not have this yet",
89
+ * and \`vc preflight\` refuses it.
90
+ */
91
+ export default defineEnv({
92
+ ${lines.join("\n")}
93
+ });
94
+ `;
95
+ }
96
+
97
+ export function renderEnvExample(manifest: Manifest): string {
98
+ const keys = allEnvKeys(manifest);
99
+ return [
100
+ "# Every key is required. Copy to .env for local development.",
101
+ "# `unset` is the one value that reads as absent — for a credential you do not have yet.",
102
+ "",
103
+ ...keys.map((key) => `${key.key}=${key.dev ?? (key.plaintext ? "" : "unset")}`),
104
+ "",
105
+ ].join("\n");
106
+ }
107
+
108
+ export function renderEnvLocal(manifest: Manifest): string {
109
+ const keys = allEnvKeys(manifest);
110
+ const local: Record<string, string> = {
111
+ SHOP_DOMAIN: `${manifest.shop.domain.split(".")[0]}.test`,
112
+ GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
113
+ SHOP_ZONE: zone(manifest),
114
+ COMMERCE_CRON_SECRET: "dev-cron-secret-not-for-production",
115
+ COMMERCE_WEBHOOK_SECRET: "whsec_dev_secret",
116
+ SYSTEM_API_KEY: "dev-system-key-0123456789abcdefghijklmnopqrstuvwxyz",
117
+ EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`,
118
+ };
119
+ return [
120
+ "# Local development. Gitignored; .env.example documents every key.",
121
+ "",
122
+ ...keys.map((key) => `${key.key}=${local[key.key] ?? key.dev ?? "unset"}`),
123
+ // One file for both halves in a strict repo, where the storefront and
124
+ // the worker share a root. The worker takes 5173 because it is started
125
+ // first; the storefront lands on the next free port and calls back here.
126
+ ...(hasFrontend(manifest.layout)
127
+ ? ["", "# The storefront, reaching the worker. Start the worker first.", "VITE_API_ORIGIN=http://localhost:5173"]
128
+ : []),
129
+ "",
130
+ ].join("\n");
131
+ }
132
+
133
+ export function renderEnvProduction(manifest: Manifest): string {
134
+ const keys = allEnvKeys(manifest).filter((key) => key.plaintext);
135
+ const values: Record<string, string> = {
136
+ SHOP_DOMAIN: manifest.shop.domain,
137
+ GITHUB_PAGES_HOST: manifest.shop.pagesHost ?? "",
138
+ SHOP_ZONE: zone(manifest),
139
+ EMAIL_FROM: `"${manifest.shop.name} <noreply@${manifest.shop.domain}>"`,
140
+ ADYEN_ENVIRONMENT: "live",
141
+ BC_ENVIRONMENT: "production",
142
+ };
143
+ return [
144
+ "# Production values that are NOT secrets.",
145
+ "#",
146
+ "# On `void deploy --backend cloudflare` every .env* file is baked into the",
147
+ "# worker's vars as PLAINTEXT, so this holds only values safe to commit. Every",
148
+ "# credential is `wrangler secret put <NAME>` instead.",
149
+ "",
150
+ ...keys.map((key) => `${key.key}=${values[key.key] ?? key.dev ?? ""}`),
151
+ "",
152
+ ].join("\n");
153
+ }
154
+
155
+ /** So the generator can say what it decided. */
156
+ export function envSummary(manifest: Manifest): { secrets: string[]; plaintext: string[] } {
157
+ const keys = allEnvKeys(manifest);
158
+ return {
159
+ secrets: keys.filter((key) => !key.plaintext).map((key) => key.key),
160
+ plaintext: keys.filter((key) => key.plaintext).map((key) => key.key),
161
+ };
162
+ }
163
+
164
+ export { has };
@@ -0,0 +1,275 @@
1
+ import type { Manifest } from "../manifest";
2
+
3
+ /**
4
+ * The storefront: a static Void site that talks to the worker over HTTP.
5
+ *
6
+ * Two layouts have one, and the only difference is where it sits.
7
+ *
8
+ * monorepo `frontend/`, its own workspace beside `api/`
9
+ * strict the REPOSITORY ROOT, sharing the one package.json
10
+ *
11
+ * Strict's placement is the interesting one. What a person edits in a strict
12
+ * repository is the storefront, so the storefront is what the repository
13
+ * looks like: its Vite config, its pages, its package.json. The backend is
14
+ * generated underneath it at `.vc/app` and is nobody's to edit. So the repo
15
+ * is, in technicality, a frontend app that builds a backend app — and in
16
+ * practice a monorepo without the ceremony of being one.
17
+ *
18
+ * Void prerenders it (`output: "static"`), and a workflow publishes the built
19
+ * tree to a branch that GitHub Pages — or Cloudflare Pages — serves as-is.
20
+ */
21
+
22
+ /** Where the frontend's build output lands, relative to its own directory. */
23
+ export const FRONTEND_OUT = "dist/client";
24
+ export const FRONTEND_BRANCH = "frontend-static";
25
+
26
+ export function renderFrontendApiTs(manifest: Manifest): string {
27
+ return `/// <reference types="vite/client" />
28
+ import { createAuthClient } from "better-auth/react";
29
+ import { commerceClient } from "@saastemly/better-commerce/client";
30
+
31
+ /**
32
+ * The one client the storefront talks through.
33
+ *
34
+ * The API is another origin — api.${manifest.shop.domain} — so its address is
35
+ * baked at build time: .env.production for the build, .env locally. There is
36
+ * no fallback. A storefront that silently points nowhere is worse than one
37
+ * that refuses to build.
38
+ */
39
+ const origin = import.meta.env.VITE_API_ORIGIN as string | undefined;
40
+ if (!origin) throw new Error("VITE_API_ORIGIN is not set — .env locally, .env.production for the build.");
41
+
42
+ export const API_ORIGIN: string = origin;
43
+ export const api = createAuthClient({ baseURL: API_ORIGIN, plugins: [commerceClient()] });
44
+ `;
45
+ }
46
+
47
+ export function renderFrontendEnvProduction(manifest: Manifest): string {
48
+ return `# Baked into the storefront build. Generated by \`vc init\` from voidcommerce.json.
49
+ VITE_API_ORIGIN=https://api.${manifest.shop.domain}
50
+ `;
51
+ }
52
+
53
+ /** The Vite config for a static Void site. Deliberately small: this is yours. */
54
+ export function renderFrontendViteConfig(): string {
55
+ return `import { defineConfig } from "vite";
56
+ import { voidPlugin } from "void";
57
+ import { voidReact } from "@void/react/plugin";
58
+
59
+ /** The storefront. The API is a different origin, so nothing is proxied here. */
60
+ export default defineConfig({
61
+ plugins: [voidPlugin(), voidReact()],
62
+ });
63
+ `;
64
+ }
65
+
66
+ /**
67
+ * `target: node` because Void 0.10.13's Cloudflare prerender calls miniflare
68
+ * with the single-worker shorthand that the miniflare its own dependencies
69
+ * pin rejects. The node target prerenders directly, and needs
70
+ * `@hono/node-server`. It also disables Cloudflare bindings, which a static
71
+ * export could not use anyway.
72
+ */
73
+ export function renderFrontendVoidJson(): string {
74
+ return `{
75
+ "$schema": "./node_modules/void/schema.json",
76
+ "target": "node",
77
+ "output": "static"
78
+ }
79
+ `;
80
+ }
81
+
82
+ export function renderFrontendTsconfig(): string {
83
+ return `{
84
+ "extends": "./.void/tsconfig.json",
85
+ "compilerOptions": {
86
+ "target": "ES2022",
87
+ "module": "ESNext",
88
+ "moduleResolution": "bundler",
89
+ "strict": true,
90
+ "esModuleInterop": true,
91
+ "skipLibCheck": true,
92
+ "noEmit": true,
93
+ "types": ["vite/client"],
94
+ "jsx": "react-jsx",
95
+ "paths": { "@/*": ["./*"] }
96
+ },
97
+ "include": ["pages", "lib", "src"]
98
+ }
99
+ `;
100
+ }
101
+
102
+ /** A storefront that actually shows the catalogue, so the first build proves the wiring. */
103
+ export function renderStorefrontPage(manifest: Manifest): string {
104
+ const { name, currency } = manifest.shop;
105
+ return `import { useEffect, useState } from "react";
106
+ import { api } from "@/lib/api";
107
+
108
+ /**
109
+ * The shop front.
110
+ *
111
+ * Yours to replace — this is a starting point, not a framework. What it is
112
+ * here to prove is the wiring: the catalogue comes from the worker over HTTP,
113
+ * with no server code on this side, which is what makes the whole storefront
114
+ * a static export.
115
+ *
116
+ * Prices arrive in MINOR units (øre, cents) because that is the only way to
117
+ * add money without losing it to floating point.
118
+ */
119
+ interface Product {
120
+ id: string;
121
+ title: string;
122
+ handle?: string;
123
+ description?: string;
124
+ priceMinor?: number;
125
+ currency?: string;
126
+ }
127
+
128
+ const money = (minor: number, currency: string) =>
129
+ new Intl.NumberFormat(undefined, { style: "currency", currency: currency.toUpperCase() }).format(minor / 100);
130
+
131
+ export default function StorefrontPage() {
132
+ const [products, setProducts] = useState<Product[] | null>(null);
133
+ const [error, setError] = useState<string | null>(null);
134
+
135
+ useEffect(() => {
136
+ // The catalogue is public: no session, no key. A visitor sees it.
137
+ api.$fetch("/commerce/products", { method: "GET" })
138
+ .then((result) => {
139
+ const data = (result as { data?: { products?: Product[] } }).data;
140
+ setProducts(data?.products ?? []);
141
+ })
142
+ .catch((cause: unknown) => setError(cause instanceof Error ? cause.message : String(cause)));
143
+ }, []);
144
+
145
+ return (
146
+ <main style={{ maxWidth: "60rem", margin: "0 auto", padding: "2rem 1rem", fontFamily: "system-ui, sans-serif" }}>
147
+ <h1>${name}</h1>
148
+ {error && <p role="alert">The catalogue could not be loaded: {error}</p>}
149
+ {!products && !error && <p>Loading the catalogue…</p>}
150
+ {products?.length === 0 && <p>No products yet. Run <code>vc import</code> to push <code>data/</code> into the shop.</p>}
151
+ <ul style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(14rem, 1fr))", gap: "1.5rem", listStyle: "none", padding: 0 }}>
152
+ {products?.map((product) => (
153
+ <li key={product.id}>
154
+ <h2 style={{ fontSize: "1rem", margin: "0 0 .25rem" }}>{product.title}</h2>
155
+ {product.description && <p style={{ margin: "0 0 .5rem", opacity: 0.8 }}>{product.description}</p>}
156
+ {typeof product.priceMinor === "number" && <p style={{ margin: 0 }}>{money(product.priceMinor, product.currency ?? "${currency}")}</p>}
157
+ </li>
158
+ ))}
159
+ </ul>
160
+ </main>
161
+ );
162
+ }
163
+ `;
164
+ }
165
+
166
+ /**
167
+ * Publishes the storefront to a branch on every push.
168
+ *
169
+ * The branch holds build output only, so it is force-pushed as a single
170
+ * orphan commit rather than accumulating history. GitHub Pages serves it from
171
+ * `/`, and the CNAME file in it sets the domain — so a green run is a live
172
+ * deploy with no Pages environment to configure. Cloudflare Pages can be
173
+ * pointed at the same branch instead.
174
+ */
175
+ export function renderFrontendWorkflow(manifest: Manifest, dir: string): string {
176
+ const at = dir ? `\n working-directory: ${dir}` : "";
177
+ const out = dir ? `${dir}/${FRONTEND_OUT}` : FRONTEND_OUT;
178
+ const paths = dir
179
+ ? ` - "${dir}/**"\n - "package.json"\n - "bun.lock"`
180
+ : ` - "pages/**"\n - "lib/**"\n - "src/**"\n - "public/**"\n - "vite.config.ts"\n - "void.json"\n - "package.json"\n - "bun.lock"`;
181
+ return `name: Publish the storefront
182
+
183
+ # Generated by \`vc init\` from voidcommerce.json.
184
+ #
185
+ # Builds the storefront and force-pushes the prerendered tree to
186
+ # \`${FRONTEND_BRANCH}\`. Point GitHub Pages (or Cloudflare Pages) at that branch
187
+ # and a push here is a live storefront. The worker deploys separately, from
188
+ # \`void-dist\`.
189
+ on:
190
+ push:
191
+ branches: [main, master]
192
+ paths:
193
+ ${paths}
194
+ - ".github/workflows/frontend-static.yml"
195
+ workflow_dispatch:
196
+
197
+ # A later push wins: an older build must never overwrite a newer one.
198
+ concurrency:
199
+ group: ${FRONTEND_BRANCH}-\${{ github.repository }}
200
+ cancel-in-progress: true
201
+
202
+ # contents: write is what lets GITHUB_TOKEN force-push the branch.
203
+ permissions:
204
+ contents: write
205
+
206
+ env:
207
+ # Served from the root at the custom domain. Set a BASE_PATH repository
208
+ # variable to "/<repo>/" only while the site is still at <owner>.github.io/<repo>/.
209
+ BASE_PATH: \${{ vars.BASE_PATH || '/' }}
210
+ # The publish step force-pushes, which would otherwise wipe the CNAME file
211
+ # GitHub keeps in the branch.
212
+ CNAME: ${manifest.shop.domain}
213
+
214
+ jobs:
215
+ build:
216
+ runs-on: ubuntu-latest
217
+ steps:
218
+ - uses: actions/checkout@v6
219
+ - uses: oven-sh/setup-bun@v2
220
+
221
+ - run: bun install --frozen-lockfile
222
+
223
+ # Generates .void/* codegen without booting Vite.
224
+ - run: bunx void prepare${at}
225
+
226
+ # void.json sets output: "static", so this prerenders every page to HTML
227
+ # under ${FRONTEND_OUT} alongside the client bundle.
228
+ - run: bunx vp build${at}
229
+
230
+ - name: Rewrite absolute URLs onto the Pages base path${at}
231
+ run: |
232
+ set -euo pipefail
233
+ BASE="\${BASE_PATH%/}/"
234
+ if [ "$BASE" = "/" ]; then
235
+ echo "BASE_PATH is /, nothing to rewrite."
236
+ exit 0
237
+ fi
238
+ # Void emits root-absolute URLs (/assets/..., /_void/...) and has no
239
+ # Vite \`base\` support, so they are rewritten here for the subpath a
240
+ # project site is served from.
241
+ find ${FRONTEND_OUT} -type f \\( -name '*.html' -o -name '*.js' -o -name '*.css' \\) -print0 \\
242
+ | xargs -0 -r sed -i \\
243
+ -e "s|\\"/assets/|\\"\${BASE}assets/|g" \\
244
+ -e "s|'/assets/|'\${BASE}assets/|g" \\
245
+ -e "s|\\"/_void/|\\"\${BASE}_void/|g" \\
246
+ -e "s|'/_void/|'\${BASE}_void/|g"
247
+ echo "Rewrote absolute URLs onto \${BASE}"
248
+
249
+ - name: Prepare the published tree${at}
250
+ run: |
251
+ set -euo pipefail
252
+ # Without .nojekyll, Pages runs Jekyll and drops _void/ (and every
253
+ # other underscore-prefixed path) from the served site.
254
+ touch ${FRONTEND_OUT}/.nojekyll
255
+ # Unknown paths fall back to the prerendered entry page so the client
256
+ # router can take over instead of showing GitHub's 404.
257
+ [ -f ${FRONTEND_OUT}/index.html ] && cp ${FRONTEND_OUT}/index.html ${FRONTEND_OUT}/404.html
258
+ echo "\${CNAME}" > ${FRONTEND_OUT}/CNAME
259
+ ls -la ${FRONTEND_OUT}
260
+
261
+ - name: Publish to ${FRONTEND_BRANCH}
262
+ env:
263
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
264
+ run: |
265
+ set -euo pipefail
266
+ cd ${out}
267
+ # A fresh single-commit history each run: the branch is build output.
268
+ git init -q -b ${FRONTEND_BRANCH}
269
+ git config user.name "github-actions[bot]"
270
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
271
+ git add -A
272
+ git commit -q -m "Storefront \${GITHUB_SHA}"
273
+ git push -f "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git" ${FRONTEND_BRANCH}
274
+ `;
275
+ }