astroidjs 0.1.2 → 0.2.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.
- package/README.md +240 -5
- package/bin/astroid.mjs +185 -9
- package/dist/analytics/index.d.ts +37 -0
- package/dist/analytics/index.js +108 -0
- package/dist/astro/csp.d.ts +64 -0
- package/dist/astro/csp.js +173 -0
- package/dist/astro/index.d.ts +1 -0
- package/dist/astro/index.js +7 -0
- package/dist/commerce/adapters.d.ts +60 -0
- package/dist/commerce/adapters.js +90 -0
- package/dist/commerce/checkout-scaffold.d.ts +42 -0
- package/dist/commerce/checkout-scaffold.js +306 -0
- package/dist/commerce/checkout.d.ts +72 -0
- package/dist/commerce/checkout.js +124 -0
- package/dist/commerce/index.d.ts +8 -0
- package/dist/commerce/index.js +9 -0
- package/dist/commerce/loader.d.ts +71 -0
- package/dist/commerce/loader.js +90 -0
- package/dist/commerce/mirror.d.ts +67 -0
- package/dist/commerce/mirror.js +203 -0
- package/dist/commerce/roles.d.ts +38 -0
- package/dist/commerce/roles.js +93 -0
- package/dist/commerce/secrets.d.ts +74 -0
- package/dist/commerce/secrets.js +129 -0
- package/dist/commerce/sync.d.ts +86 -0
- package/dist/commerce/sync.js +154 -0
- package/dist/components/sections.d.ts +577 -0
- package/dist/components/sections.js +425 -0
- package/dist/config.d.ts +174 -12
- package/dist/config.js +43 -1
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.js +5 -0
- package/dist/email/inquiry.d.ts +33 -0
- package/dist/email/inquiry.js +63 -0
- package/dist/email/send.d.ts +120 -0
- package/dist/email/send.js +196 -0
- package/dist/email/templates.d.ts +24 -0
- package/dist/email/templates.js +184 -0
- package/dist/email/theme.d.ts +24 -0
- package/dist/email/theme.js +150 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/map/index.d.ts +3 -0
- package/dist/map/index.js +4 -0
- package/dist/map/pmtiles.d.ts +92 -0
- package/dist/map/pmtiles.js +130 -0
- package/dist/map/scaffold.d.ts +29 -0
- package/dist/map/scaffold.js +212 -0
- package/dist/map/style.d.ts +58 -0
- package/dist/map/style.js +154 -0
- package/dist/portal/config.d.ts +26 -0
- package/dist/portal/config.js +50 -0
- package/dist/portal/guard.d.ts +48 -0
- package/dist/portal/guard.js +64 -0
- package/dist/portal/index.d.ts +5 -0
- package/dist/portal/index.js +6 -0
- package/dist/portal/nav.d.ts +26 -0
- package/dist/portal/nav.js +35 -0
- package/dist/portal/scaffold.d.ts +28 -0
- package/dist/portal/scaffold.js +140 -0
- package/dist/portal/session.d.ts +36 -0
- package/dist/portal/session.js +86 -0
- package/dist/portfolio/index.d.ts +1 -0
- package/dist/portfolio/index.js +4 -0
- package/dist/portfolio/scaffold.d.ts +9 -0
- package/dist/portfolio/scaffold.js +93 -0
- package/dist/project/actions.d.ts +3 -0
- package/dist/project/actions.js +106 -0
- package/dist/project/generate.d.ts +15 -0
- package/dist/project/generate.js +144 -2
- package/dist/project/index.d.ts +2 -0
- package/dist/project/index.js +2 -0
- package/dist/project/scaffold.d.ts +29 -0
- package/dist/project/scaffold.js +140 -0
- package/dist/pwa/generate.d.ts +49 -0
- package/dist/pwa/generate.js +218 -0
- package/dist/pwa/index.d.ts +1 -0
- package/dist/pwa/index.js +2 -0
- package/dist/queues/consumer.d.ts +29 -0
- package/dist/queues/consumer.js +37 -0
- package/dist/queues/index.d.ts +4 -0
- package/dist/queues/index.js +5 -0
- package/dist/queues/messages.d.ts +60 -0
- package/dist/queues/messages.js +71 -0
- package/dist/queues/scaffold.d.ts +44 -0
- package/dist/queues/scaffold.js +204 -0
- package/dist/queues/webhook.d.ts +60 -0
- package/dist/queues/webhook.js +81 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +4 -0
- package/dist/realtime/scaffold.d.ts +30 -0
- package/dist/realtime/scaffold.js +159 -0
- package/dist/schema/collections.d.ts +41 -7
- package/dist/schema/collections.js +101 -12
- package/dist/schema/generate.js +10 -1
- package/dist/secrets.d.ts +54 -0
- package/dist/secrets.js +80 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/rate-rules.d.ts +21 -0
- package/dist/security/rate-rules.js +107 -0
- package/dist/seo/index.d.ts +3 -0
- package/dist/seo/index.js +4 -0
- package/dist/seo/resolve.d.ts +68 -0
- package/dist/seo/resolve.js +73 -0
- package/dist/seo/routes.d.ts +44 -0
- package/dist/seo/routes.js +104 -0
- package/dist/seo/structured-data.d.ts +51 -0
- package/dist/seo/structured-data.js +105 -0
- package/dist/status.d.ts +51 -0
- package/dist/status.js +113 -0
- package/dist/worker/generate.d.ts +18 -10
- package/dist/worker/generate.js +325 -37
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +42 -0
- package/dist/workflow/advance.d.ts +102 -0
- package/dist/workflow/advance.js +145 -0
- package/dist/workflow/config.d.ts +60 -0
- package/dist/workflow/config.js +73 -0
- package/dist/workflow/generate.d.ts +22 -0
- package/dist/workflow/generate.js +138 -0
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.js +4 -0
- package/package.json +21 -4
- package/src/components/Editable.astro +33 -9
- package/src/components/JustifiedGallery.astro +254 -0
- package/src/components/MediaSlot.astro +178 -0
- package/src/components/PortalShell.astro +80 -0
- package/src/components/RegisterSW.astro +45 -0
- package/src/components/Section.astro +101 -35
- package/src/components/Sections.astro +64 -0
- package/src/components/Seo.astro +57 -0
- package/src/components/StageBar.astro +137 -0
- package/src/components/StructuredData.astro +33 -0
- package/src/components/justify.ts +170 -0
- package/src/components/media-meta.ts +174 -0
- package/src/components/sections/AboutIntro.astro +46 -0
- package/src/components/sections/Banner.astro +31 -0
- package/src/components/sections/Contact.astro +22 -9
- package/src/components/sections/Cta.astro +33 -10
- package/src/components/sections/Faq.astro +50 -0
- package/src/components/sections/FeatureGrid.astro +40 -11
- package/src/components/sections/Gallery.astro +46 -0
- package/src/components/sections/Hero.astro +40 -12
- package/src/components/sections/LocationHours.astro +59 -0
- package/src/components/sections/Media.astro +44 -0
- package/src/components/sections/PricingTiers.astro +79 -0
- package/src/components/sections/ProductGrid.astro +73 -0
- package/src/components/sections/SplitImage.astro +61 -0
- package/src/components/sections/Steps.astro +58 -0
- package/src/components/sections/Testimonial.astro +51 -0
- package/src/components/sections.ts +452 -67
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ export default defineAstroid({
|
|
|
46
46
|
key: "coracle",
|
|
47
47
|
archetype: "storefront",
|
|
48
48
|
theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
|
|
49
|
-
sections: ["hero", "
|
|
49
|
+
sections: ["hero", "banner", "productGrid", "locationHours", "contact"],
|
|
50
50
|
commerce: { provider: "square" },
|
|
51
51
|
deploy: { platform: "cloudflare" },
|
|
52
52
|
});
|
|
@@ -59,12 +59,247 @@ export default defineAstroid({
|
|
|
59
59
|
key: "megbowen",
|
|
60
60
|
archetype: "portfolio",
|
|
61
61
|
theme: { name: "Meg Bowen Studio", colors: { brand: "#2b2b2b" } },
|
|
62
|
-
sections: ["hero", "gallery", "
|
|
63
|
-
portal: { enabled: true
|
|
62
|
+
sections: ["hero", "gallery", "aboutIntro", "contact"],
|
|
63
|
+
portal: { enabled: true },
|
|
64
64
|
deploy: { platform: "cloudflare" },
|
|
65
65
|
});
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
## Commerce
|
|
69
|
+
|
|
70
|
+
**Providers fill roles.** Not "the commerce provider" — the toolkit's clients
|
|
71
|
+
make that impossible: `commerce/stripe` has no catalog API, `commerce/fourthwall`
|
|
72
|
+
has no invoicing, Square does both. So `storefront` and `invoicing` are assigned
|
|
73
|
+
independently, and a provider put in a role it can't serve fails at config load
|
|
74
|
+
rather than at runtime on the first invoice.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
commerce: { storefront: "fourthwall", invoicing: "stripe" } // two providers
|
|
78
|
+
commerce: { provider: "square" } // shorthand → storefront
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**The catalog is mirrored with a pulled/owned split.** The provider is the source
|
|
82
|
+
of truth; D1 holds the owner's edits. `mode: "mirror"` keeps the catalog fields
|
|
83
|
+
in D1 (fast reads, briefly stale); `mode: "overlay"` keeps only the owner's
|
|
84
|
+
columns (never stale, one provider round-trip per read). The sync **never writes
|
|
85
|
+
an owned column** — a sync that does silently reverts the owner's work, and
|
|
86
|
+
they find out days later. `slug` is owned for that reason: it's the public URL,
|
|
87
|
+
so a provider rename must not break links.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
commerce: {
|
|
91
|
+
provider: "square",
|
|
92
|
+
catalog: { owned: { tone: { type: "text", values: ["cream", "teal"] } } },
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Everything else follows from that table. `astroidCatalogLoaderConfig` reads it
|
|
97
|
+
for the Live Content Collection, and the adapters normalize before the row is
|
|
98
|
+
written — so one loader definition serves a Square site and a Fourthwall site,
|
|
99
|
+
which is the drift the module exists to kill.
|
|
100
|
+
|
|
101
|
+
**Checkout is server-authoritative.** `verifyCheckout` treats the client's price
|
|
102
|
+
as a staleness check, never an input to the charge: re-price server-side, refuse
|
|
103
|
+
on mismatch. `checkoutIdempotencyKey` derives a stable key from the verified cart
|
|
104
|
+
**and a required `identity`**, so a double-clicked Pay button charges once —
|
|
105
|
+
while two customers buying the same thing stay two charges.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const key = await checkoutIdempotencyKey(check, "order", cartId);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Pass something stable across a retry of this attempt and distinct between buyers
|
|
112
|
+
(a cart id, checkout-session id, or portal user id). It is required, and empty is
|
|
113
|
+
refused, because a key derived from cart contents alone collides between
|
|
114
|
+
customers: providers scope idempotency keys per account for ~24h, so the second
|
|
115
|
+
buyer's charge is deduped into the first buyer's order and never happens.
|
|
116
|
+
|
|
117
|
+
## The webhook pipeline
|
|
118
|
+
|
|
119
|
+
Configure `commerce` and the generated worker stops being fetch-only: it composes
|
|
120
|
+
Astro's SSR handler with a **queue consumer** and a **cron**, and the scaffold
|
|
121
|
+
gains a webhook receiver plus a consumer seam. `--commerce <provider>` on
|
|
122
|
+
`pnpm create astroid` sets it all up.
|
|
123
|
+
|
|
124
|
+
The receiver's ordering is the part worth knowing. `handleWebhook` verifies the
|
|
125
|
+
HMAC over the **raw body before anything parses it** — parse first and an
|
|
126
|
+
unauthenticated caller reaches the JSON parser and everything downstream, and
|
|
127
|
+
re-serializing a parsed body to check a signature is how signature checks quietly
|
|
128
|
+
stop checking anything. It then enqueues and returns, so the response doesn't
|
|
129
|
+
wait on the work.
|
|
130
|
+
|
|
131
|
+
Status codes are the only backpressure signal a provider gives you, so each one
|
|
132
|
+
is picked for what it tells the sender to do:
|
|
133
|
+
|
|
134
|
+
| Situation | Code | Why |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| Secret unprovisioned | 503 | Dormant is temporary — keep retrying so events delivered before you set the secret still land |
|
|
137
|
+
| Bad / missing signature | 401 | Terminal. It won't verify on retry either, and retrying turns a misconfiguration into a flood |
|
|
138
|
+
| Body isn't JSON | 400 | Terminal for the same reason |
|
|
139
|
+
| Enqueue failed | 503 | The signature checked out, so the event is real — ask for redelivery |
|
|
140
|
+
| Enqueued | 202 | Accepted, not done. That's the point of a queue |
|
|
141
|
+
|
|
142
|
+
On the consumer side `astroidQueueHandler` owns the dispatch every site wrote: a
|
|
143
|
+
periodic refresh re-syncs, a webhook re-syncs *only* if it touched the catalog,
|
|
144
|
+
and everything else acks as a no-op. That last part matters — order and payment
|
|
145
|
+
events arrive in volume and have nothing local to update, so treating them as
|
|
146
|
+
actionable turns a busy sales day into a refresh storm.
|
|
147
|
+
|
|
148
|
+
The cron **enqueues** rather than running inline, so the safety-net re-sync takes
|
|
149
|
+
the same retry and DLQ path as everything else. Retries and DLQ routing live in
|
|
150
|
+
`wrangler.jsonc`, because they're Cloudflare's job, not the consumer's.
|
|
151
|
+
|
|
152
|
+
## Transactional email
|
|
153
|
+
|
|
154
|
+
Four templates — sign-in link, password reset, and the inquiry pair (notify the
|
|
155
|
+
owner, confirm to the sender) — over the toolkit's email shell. Each renders HTML
|
|
156
|
+
**and** plaintext from one definition: a message with no text/plain part scores
|
|
157
|
+
worse with spam filters, and for a sign-in link the plaintext body is what a
|
|
158
|
+
terminal client shows and what the dev log prints.
|
|
159
|
+
|
|
160
|
+
`astroidMailTheme(config)` derives the whole mail theme from `theme.colors`.
|
|
161
|
+
Neutrals stay fixed (they're typography choices, not brand ones); what varies is
|
|
162
|
+
the accent and the five-cell masthead band, built as a ramp so it reads as
|
|
163
|
+
designed whether you configured one brand colour or three. The accent is
|
|
164
|
+
**contrast-corrected** — a brand yellow used verbatim as 11px uppercase text on a
|
|
165
|
+
near-white card is unreadable, and mail clients have no dark-mode escape hatch.
|
|
166
|
+
Pass overrides for any slot you want to own.
|
|
167
|
+
|
|
168
|
+
Delivery is best-effort and never throws. Mail here is always the notification of
|
|
169
|
+
something already durable — the inquiry row is inserted, the account exists — so
|
|
170
|
+
a failure must not fail the request that caused it, and messages send
|
|
171
|
+
independently so the owner's copy still arrives when a visitor typos their
|
|
172
|
+
address. With no `EMAIL` binding the mailer is **dormant**: it logs the rendered
|
|
173
|
+
message instead of dropping it, which is what makes "click the magic link" work
|
|
174
|
+
under `wrangler dev`.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
formRoute({
|
|
178
|
+
form: contactForm,
|
|
179
|
+
// Fires after the insert, off the response path — store-and-forward.
|
|
180
|
+
onSubmit: (values, env) => sendInquiryMail(astroidConfig, env, values),
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## SEO
|
|
185
|
+
|
|
186
|
+
A settings-driven head, structured data, and the two crawler files — first-party,
|
|
187
|
+
no `astro-seo` dependency.
|
|
188
|
+
|
|
189
|
+
`<Seo>` resolves three levels (page override → the page's own default →
|
|
190
|
+
`site_settings`) with one rule worth knowing: an **empty string is unset**, so
|
|
191
|
+
clearing a field in the editor falls back instead of publishing a blank `<meta>`.
|
|
192
|
+
The title template applies only when a page supplies its own title, so the home
|
|
193
|
+
page reads `Acme Coffee`, not `Acme Coffee | Acme Coffee`. `disableIndexing` in
|
|
194
|
+
settings is a site-wide kill switch that beats any page asking to be indexed —
|
|
195
|
+
useful for staging.
|
|
196
|
+
|
|
197
|
+
`<StructuredData>` emits a schema.org `@graph`: the business, the `WebSite`, and
|
|
198
|
+
optionally the entity the page is *about* (a Product, a VisualArtwork). The
|
|
199
|
+
business `@type` comes from the archetype (`storefront` → `Store`, `portfolio` →
|
|
200
|
+
`Person`); set `seo.businessType` to a narrower subtype whenever you know one.
|
|
201
|
+
The payload is escaped with `escapeJsonLd`, not `JSON.stringify` — `stringify`
|
|
202
|
+
doesn't escape `<`, so an editor-authored value containing `</script>` would
|
|
203
|
+
close the tag early and inject markup into `<head>`.
|
|
204
|
+
|
|
205
|
+
`robots.txt` and `sitemap.xml` derive their disallow list from the same config
|
|
206
|
+
(`astroidNoindexPaths`), so the two files can't disagree about what's crawlable.
|
|
207
|
+
Both are **origin-aware** — built from the serving origin rather than a
|
|
208
|
+
configured domain, because a preview deploy advertising the production host
|
|
209
|
+
invites its content to be indexed under the real domain.
|
|
210
|
+
|
|
211
|
+
## Security defaults
|
|
212
|
+
|
|
213
|
+
Two stack-wide concerns every site was re-deriving by hand, moved into the
|
|
214
|
+
framework.
|
|
215
|
+
|
|
216
|
+
**Rate-limit rules are data, derived from the config.** The generated middleware
|
|
217
|
+
calls `astroidRateRules(config)`: the editor magic-link always (the
|
|
218
|
+
email-bombing target, so the tightest budget in the set), the portal's
|
|
219
|
+
credential surfaces when `portal.enabled`, checkout when `commerce` is set. The
|
|
220
|
+
session-gated editor API stays out on purpose — a limiter that can lock the
|
|
221
|
+
owner out of their own studio is worse than the abuse it stops. Add or override
|
|
222
|
+
via `security.rateRules`, which is matched *before* the defaults, so you replace
|
|
223
|
+
one budget rather than the whole set.
|
|
224
|
+
|
|
225
|
+
**The CSP is composed, and it's split for a reason.** `astroidSecurity(config)`
|
|
226
|
+
gives `astro.config.mjs` its `security` block. Astro owns `script-src` — it
|
|
227
|
+
hashes every script it processes, so the policy needs no `'unsafe-inline'` — and
|
|
228
|
+
Astroid adds the one hash Astro can't produce itself: Solid's hydration
|
|
229
|
+
bootstrap, injected by `@astrojs/solid-js` on every page with an island.
|
|
230
|
+
Computing it from `generateHydrationScript()` means it tracks solid-js upgrades
|
|
231
|
+
instead of going stale as a copy-pasted literal. Meanwhile the generated
|
|
232
|
+
middleware rewrites *only* `style-src`, because Louise's data-driven `style=""`
|
|
233
|
+
carriers need `'unsafe-inline'` and a single hash in that directive would void
|
|
234
|
+
it per spec — the two cannot share one directive.
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
// astro.config.mjs
|
|
238
|
+
import { ASTROID_VITE_BUILD, astroidSecurity } from "astroidjs/astro";
|
|
239
|
+
import astroidConfig from "./astroid.config.ts";
|
|
240
|
+
|
|
241
|
+
export default defineConfig({
|
|
242
|
+
security: astroidSecurity(astroidConfig),
|
|
243
|
+
vite: { build: { ...ASTROID_VITE_BUILD } }, // assetsInlineLimit: 0 — an
|
|
244
|
+
}); // inlined asset can't be hashed
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Enabled modules contribute their own origins (a commerce provider's SDK hosts,
|
|
248
|
+
the captcha frame); `security.cspOrigins` adds anything Astroid can't see.
|
|
249
|
+
|
|
250
|
+
## Modules are dormant, not broken
|
|
251
|
+
|
|
252
|
+
Astroid's optional modules are opt-in at the *config* level, never at the
|
|
253
|
+
*account* level: switching commerce on must not require a Square account before
|
|
254
|
+
`pnpm dev` will boot. So a module whose secrets aren't provisioned is **dormant**
|
|
255
|
+
— it renders, it serves, it says out loud that it's simulated, and it never calls
|
|
256
|
+
upstream with a dummy credential. A fresh clone runs with zero external accounts.
|
|
257
|
+
|
|
258
|
+
`create-astroid` seeds every module secret with one loud sentinel,
|
|
259
|
+
`DUMMY_REPLACE_ME`, so a scaffold has a complete and valid binding set and no
|
|
260
|
+
real credentials. Reading a secret back that still holds the sentinel — or that
|
|
261
|
+
is absent, empty, or bound to an unprovisioned store — yields `null`:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { resolveModuleSecrets, describeModuleStatus } from "astroidjs";
|
|
265
|
+
|
|
266
|
+
const secrets = await resolveModuleSecrets({
|
|
267
|
+
SQUARE_ACCESS_TOKEN: env.SQUARE_ACCESS_TOKEN,
|
|
268
|
+
SQUARE_WEBHOOK_SECRET: env.SQUARE_WEBHOOK_SECRET,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (!secrets.configured) {
|
|
272
|
+
console.warn(describeModuleStatus("commerce", secrets));
|
|
273
|
+
// → commerce: dormant (simulated) — unprovisioned secret(s): SQUARE_WEBHOOK_SECRET
|
|
274
|
+
return simulatedCheckout();
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Partial provisioning counts as dormant. A half-configured integration fails
|
|
279
|
+
mid-checkout rather than at boot, which is precisely the failure this convention
|
|
280
|
+
exists to prevent.
|
|
281
|
+
|
|
282
|
+
The scaffold ships one worked example: Turnstile captcha on the **editor
|
|
283
|
+
sign-in**, seeded with the sentinel secret plus Cloudflare's always-passing test
|
|
284
|
+
site key, enforcing only once **both** halves are real — so provisioning one of
|
|
285
|
+
them can't lock you out of your own sign-in.
|
|
286
|
+
|
|
287
|
+
Both halves matter, and the second one is the reason this is worth spelling out.
|
|
288
|
+
`getLouiseAuth` registers Better Auth's captcha plugin on `/sign-in/magic-link`
|
|
289
|
+
as soon as the pair is real, and that plugin rejects any request without an
|
|
290
|
+
`x-captcha-response` header. So the login page renders the widget under exactly
|
|
291
|
+
the same condition the server arms the check — `turnstileSiteKey` returns null
|
|
292
|
+
for the test key, the same test `activeCaptchaSecret` applies — and forwards the
|
|
293
|
+
token in that header. A gate that turns on server-side while the page keeps
|
|
294
|
+
posting without a token is not a half-configured integration; it is a locked
|
|
295
|
+
door with the owner outside.
|
|
296
|
+
|
|
297
|
+
The **public contact form** is a separate surface and is not captcha-gated: its
|
|
298
|
+
spam defence is the honeypot, the minimum time-to-submit, and the rate limit.
|
|
299
|
+
`FormSpamConfig.turnstile` exists in the toolkit if you want to add it, but
|
|
300
|
+
`formRoute` is generated without `turnstileSecret`, so switching the flag on
|
|
301
|
+
alone would not enforce anything.
|
|
302
|
+
|
|
68
303
|
## CLI
|
|
69
304
|
|
|
70
305
|
The `astroid` command turns the config into the Louise wiring and keeps it in
|
|
@@ -87,7 +322,7 @@ The generated trio carries a "do not hand-edit" banner — `generate` (and
|
|
|
87
322
|
config to catch drift. Your `wrangler.jsonc` is scaffolded once and then yours to
|
|
88
323
|
edit (real binding ids, secrets); `generate` never touches it.
|
|
89
324
|
|
|
90
|
-
New projects come from the `create-astroid` scaffold (`
|
|
325
|
+
New projects come from the `create-astroid` scaffold (`pnpm create astroid`), which
|
|
91
326
|
writes the floor — config, the generated trio, `wrangler.jsonc`, and the baseline
|
|
92
327
|
Astro app — in one step.
|
|
93
328
|
|
|
@@ -98,7 +333,7 @@ Astro app — in one step.
|
|
|
98
333
|
3. ✅ Config → generated `worker.ts` + middleware (no hand-wired route ordering).
|
|
99
334
|
4. ✅ `<Section>` / `<Editable>` / `<Collection>` component primitives.
|
|
100
335
|
5. ✅ **CLI** — `astroid generate / doctor / dev / build / deploy`; `create-astroid`
|
|
101
|
-
scaffold (`
|
|
336
|
+
scaffold (`pnpm create astroid`).
|
|
102
337
|
|
|
103
338
|
## License
|
|
104
339
|
|
package/bin/astroid.mjs
CHANGED
|
@@ -77,7 +77,7 @@ async function loadConfig(cwd, explicit) {
|
|
|
77
77
|
|
|
78
78
|
// --- commands --------------------------------------------------------------
|
|
79
79
|
async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
|
|
80
|
-
const { generateAstroidProject } = await import(GENERATORS_URL);
|
|
80
|
+
const { generateAstroidProject, generateAstroidScaffoldFiles } = await import(GENERATORS_URL);
|
|
81
81
|
const { config } = await loadConfig(cwd, flags.config);
|
|
82
82
|
const files = generateAstroidProject(config);
|
|
83
83
|
for (const file of files) {
|
|
@@ -86,12 +86,46 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
|
|
|
86
86
|
writeFileSync(abs, file.contents);
|
|
87
87
|
if (!quiet) out(` ✓ ${file.path}`);
|
|
88
88
|
}
|
|
89
|
-
|
|
89
|
+
|
|
90
|
+
// Scaffold-once files for whatever modules the config switched on.
|
|
91
|
+
//
|
|
92
|
+
// Written only when ABSENT — each is a seam the project owns, so overwriting
|
|
93
|
+
// one would destroy the edit it exists to hold. But a MISSING one is not a
|
|
94
|
+
// choice the user made: the trio above emits static imports of `./queue.js`
|
|
95
|
+
// and `./portal-auth.js`, so a config that gained a module after scaffold
|
|
96
|
+
// regenerated a project that couldn't resolve its own imports. Completing the
|
|
97
|
+
// config change is what makes "one typed config" true.
|
|
98
|
+
const created = [];
|
|
99
|
+
for (const file of generateAstroidScaffoldFiles(config)) {
|
|
100
|
+
const abs = join(cwd, file.path);
|
|
101
|
+
const exists = existsSync(abs);
|
|
102
|
+
if (file.apply === "append-once") {
|
|
103
|
+
const current = exists ? readFileSync(abs, "utf8") : "";
|
|
104
|
+
if (file.marker && current.includes(file.marker)) continue;
|
|
105
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
106
|
+
writeFileSync(abs, current + file.contents);
|
|
107
|
+
created.push(file.path);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (exists) continue;
|
|
111
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
112
|
+
writeFileSync(abs, file.contents);
|
|
113
|
+
created.push(file.path);
|
|
114
|
+
}
|
|
115
|
+
for (const path of created) if (!quiet) out(` + ${path} (scaffolded — yours to edit)`);
|
|
116
|
+
|
|
117
|
+
if (!quiet) {
|
|
118
|
+
out(`Generated ${files.length} file(s) from your defineAstroid config.`);
|
|
119
|
+
if (created.length > 0) {
|
|
120
|
+
out(`Scaffolded ${created.length} new module file(s); existing ones were left alone.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
90
123
|
return files;
|
|
91
124
|
}
|
|
92
125
|
|
|
93
126
|
async function cmdDoctor(cwd, flags) {
|
|
94
|
-
const { generateAstroidProject } =
|
|
127
|
+
const { generateAstroidProject, generateAstroidScaffoldFiles, astroidUsesQueues } =
|
|
128
|
+
await import(GENERATORS_URL);
|
|
95
129
|
const { config, path: configPath } = await loadConfig(cwd, flags.config);
|
|
96
130
|
|
|
97
131
|
const problems = []; // { level: "error" | "warn", msg }
|
|
@@ -99,6 +133,8 @@ async function cmdDoctor(cwd, flags) {
|
|
|
99
133
|
const warn = (msg) => problems.push({ level: "warn", msg });
|
|
100
134
|
const oks = [];
|
|
101
135
|
const ok = (msg) => oks.push(msg);
|
|
136
|
+
// Neither pass nor problem: informational lines about what's switched on.
|
|
137
|
+
const notes = [];
|
|
102
138
|
|
|
103
139
|
ok(`config loads and validates (${rel(cwd, configPath)})`);
|
|
104
140
|
|
|
@@ -108,12 +144,28 @@ async function cmdDoctor(cwd, flags) {
|
|
|
108
144
|
if (!existsSync(abs)) {
|
|
109
145
|
err(`${file.path} is missing — run \`astroid generate\`.`);
|
|
110
146
|
} else if (readFileSync(abs, "utf8") !== file.contents) {
|
|
111
|
-
|
|
147
|
+
// An ERROR, not a warning. These three carry a "do not hand-edit" banner
|
|
148
|
+
// and are a pure function of the config, so "stale" means the project on
|
|
149
|
+
// disk is not the project the config describes — which is the single
|
|
150
|
+
// condition doctor exists to catch. As a warning it printed "healthy" and
|
|
151
|
+
// exited 0, so `pnpm doctor` could not gate CI on it.
|
|
152
|
+
err(`${file.path} is stale (out of sync with your config) — run \`astroid generate\`.`);
|
|
112
153
|
} else {
|
|
113
154
|
ok(`${file.path} is up to date`);
|
|
114
155
|
}
|
|
115
156
|
}
|
|
116
157
|
|
|
158
|
+
// 1b. Scaffold-once module files. A missing one is an ERROR: the trio above
|
|
159
|
+
// emits static imports of `./queue.js` / `./portal-auth.js`, so a config
|
|
160
|
+
// that names a module whose seam was never written produces a project that
|
|
161
|
+
// cannot resolve its own imports. This is precisely the state that used to
|
|
162
|
+
// report "healthy, 2 warning(s)" and exit 0.
|
|
163
|
+
for (const file of generateAstroidScaffoldFiles(config)) {
|
|
164
|
+
if (file.apply === "append-once") continue; // accumulated, not owned — see below
|
|
165
|
+
if (existsSync(join(cwd, file.path))) ok(`${file.path} present`);
|
|
166
|
+
else err(`${file.path} is missing (required by your config) — run \`astroid generate\`.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
117
169
|
// 2. wrangler.jsonc bindings — presence checks + placeholder detection. Read as
|
|
118
170
|
// text (JSONC with comments/trailing commas) rather than parse, to stay robust.
|
|
119
171
|
const wranglerPath = join(cwd, "wrangler.jsonc");
|
|
@@ -122,6 +174,50 @@ async function cmdDoctor(cwd, flags) {
|
|
|
122
174
|
} else {
|
|
123
175
|
const w = readFileSync(wranglerPath, "utf8");
|
|
124
176
|
const hasBinding = (name) => new RegExp(`"binding"\\s*:\\s*"${name}"`).test(w);
|
|
177
|
+
|
|
178
|
+
// wrangler.jsonc is scaffold-once (so a provisioned binding id is never
|
|
179
|
+
// clobbered), which means a config change can never update it. Nothing
|
|
180
|
+
// detected the resulting drift: switching on commerce made the generated
|
|
181
|
+
// worker call `env.COMMERCE_QUEUE.send()` against a wrangler.jsonc with no
|
|
182
|
+
// queues block at all, and both doctor and deploy reported success.
|
|
183
|
+
//
|
|
184
|
+
// So check every binding the GENERATED code actually dereferences, not just
|
|
185
|
+
// the three the baseline happens to have.
|
|
186
|
+
const requiredBindings = [
|
|
187
|
+
{ name: "RL", what: "KV namespace", why: "the rate limiter in src/middleware.ts" },
|
|
188
|
+
{ name: "DRAFTS", what: "KV namespace", why: "the autosave draft buffer" },
|
|
189
|
+
...(astroidUsesQueues(config)
|
|
190
|
+
? [
|
|
191
|
+
{
|
|
192
|
+
name: "COMMERCE_QUEUE",
|
|
193
|
+
what: "Queue",
|
|
194
|
+
why: "the webhook producer in src/worker.ts",
|
|
195
|
+
},
|
|
196
|
+
]
|
|
197
|
+
: []),
|
|
198
|
+
];
|
|
199
|
+
for (const b of requiredBindings) {
|
|
200
|
+
if (hasBinding(b.name)) ok(`wrangler: ${b.what} \`${b.name}\` binding present`);
|
|
201
|
+
else
|
|
202
|
+
err(
|
|
203
|
+
`wrangler.jsonc has no ${b.what} \`${b.name}\` binding, but ${b.why} uses it. ` +
|
|
204
|
+
"Your config gained a module after this file was scaffolded — add the binding by hand " +
|
|
205
|
+
"(wrangler.jsonc is never regenerated, so provisioned ids are never clobbered).",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Email Sending declares its binding as `"name"`, not `"binding"`, so the
|
|
210
|
+
// regex above cannot see it. Checked separately because sign-in depends on
|
|
211
|
+
// it: the magic link is console-logged in dev and EMAILED in production, so
|
|
212
|
+
// a missing binding is a site nobody can sign in to — and it fails only once
|
|
213
|
+
// deployed, which is the one place nothing in this repo exercises.
|
|
214
|
+
if (/"send_email"\s*:/.test(w)) ok("wrangler: Email Sending `EMAIL` binding present");
|
|
215
|
+
else
|
|
216
|
+
err(
|
|
217
|
+
"wrangler.jsonc has no `send_email` binding, but production sign-in emails the " +
|
|
218
|
+
"magic link (it is only console-logged in dev). Add: \"send_email\": [{ \"name\": \"EMAIL\" }]",
|
|
219
|
+
);
|
|
220
|
+
|
|
125
221
|
if (hasBinding("DB")) ok("wrangler: D1 `DB` binding present");
|
|
126
222
|
else err("wrangler.jsonc has no D1 `DB` binding.");
|
|
127
223
|
if (hasBinding("MEDIA")) ok("wrangler: R2 `MEDIA` binding present");
|
|
@@ -141,8 +237,45 @@ async function cmdDoctor(cwd, flags) {
|
|
|
141
237
|
if (existsSync(join(cwd, "migrations"))) ok("migrations/ directory present");
|
|
142
238
|
else warn("no migrations/ directory — create your D1 schema migrations there.");
|
|
143
239
|
|
|
240
|
+
// 4. Local secret provisioning — which modules will run dormant under
|
|
241
|
+
// `astroid dev`, and what to set to wake them.
|
|
242
|
+
//
|
|
243
|
+
// Scoped deliberately to SECRETS, read from .dev.vars. Doctor is a static
|
|
244
|
+
// CLI: it cannot see runtime bindings (EMAIL, the queue), so claiming
|
|
245
|
+
// "email is dormant" here would be reporting its own blindness as the
|
|
246
|
+
// project's state. What it CAN say for certain is whether a value is still
|
|
247
|
+
// the placeholder — and that is the half developers actually forget.
|
|
248
|
+
//
|
|
249
|
+
// A dormant module is never an error. It is the documented default, and a
|
|
250
|
+
// fresh scaffold is expected to report every module dormant.
|
|
251
|
+
const { astroidSecretNames, ASTROID_SECRET_PLACEHOLDER } = await import(GENERATORS_URL);
|
|
252
|
+
const devVarsPath = join(cwd, ".dev.vars");
|
|
253
|
+
const devVars = existsSync(devVarsPath) ? parseDotEnv(readFileSync(devVarsPath, "utf8")) : null;
|
|
254
|
+
|
|
255
|
+
if (!devVars) {
|
|
256
|
+
warn(
|
|
257
|
+
"no .dev.vars — every module runs dormant (simulated) locally. " +
|
|
258
|
+
"Copy .env.example to .dev.vars to change that.",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
for (const [moduleName, names] of Object.entries(astroidSecretNames(config))) {
|
|
262
|
+
if (names.length === 0) continue;
|
|
263
|
+
const unset = names.filter((name) => {
|
|
264
|
+
const value = (devVars?.[name] ?? "").trim();
|
|
265
|
+
return value === "" || value === ASTROID_SECRET_PLACEHOLDER;
|
|
266
|
+
});
|
|
267
|
+
// `core` is not a module — its secrets each gate their own thing (sessions
|
|
268
|
+
// fail closed off localhost, Turnstile needs a matched pair), so a blanket
|
|
269
|
+
// "dormant" line would be wrong. Report it as provisioning, not dormancy.
|
|
270
|
+
if (unset.length === 0) ok(`${moduleName}: all secrets provisioned in .dev.vars`);
|
|
271
|
+
else if (moduleName === "core")
|
|
272
|
+
notes.push(`core: ${unset.length} secret(s) unset locally (${unset.join(", ")})`);
|
|
273
|
+
else notes.push(`${moduleName}: dormant (simulated) — unprovisioned: ${unset.join(", ")}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
144
276
|
// --- report ---
|
|
145
277
|
for (const m of oks) out(` ✓ ${m}`);
|
|
278
|
+
for (const n of notes) out(` · ${n}`);
|
|
146
279
|
for (const p of problems) {
|
|
147
280
|
if (p.level === "warn") out(` ! ${p.msg}`);
|
|
148
281
|
else out(` ✗ ${p.msg}`);
|
|
@@ -206,6 +339,15 @@ function readWranglerFacts(text) {
|
|
|
206
339
|
binding: m[1],
|
|
207
340
|
id: m[2],
|
|
208
341
|
})),
|
|
342
|
+
// Queue names, from the producer + the consumer's dead_letter_queue. Unlike
|
|
343
|
+
// D1/R2/KV these are referenced BY NAME, so there's no id to patch back —
|
|
344
|
+
// creating them is the whole job.
|
|
345
|
+
queues: [
|
|
346
|
+
...new Set([
|
|
347
|
+
...[...text.matchAll(/"queue":\s*"([^"]+)"/g)].map((m) => m[1]),
|
|
348
|
+
...[...text.matchAll(/"dead_letter_queue":\s*"([^"]+)"/g)].map((m) => m[1]),
|
|
349
|
+
]),
|
|
350
|
+
],
|
|
209
351
|
// Real (uncommented) account_id line, filled in?
|
|
210
352
|
hasAccount: /^\s*"account_id":\s*"[^<][^"]*"/m.test(text),
|
|
211
353
|
};
|
|
@@ -225,6 +367,11 @@ function provisionPlan(facts) {
|
|
|
225
367
|
steps.push({ kind: "kv", name: binding, args: ["kv", "namespace", "create", binding] });
|
|
226
368
|
}
|
|
227
369
|
}
|
|
370
|
+
// Queues carry no id, so there's no placeholder to test — creating one that
|
|
371
|
+
// already exists just errors, which the runner tolerates (same as R2).
|
|
372
|
+
for (const queue of facts.queues) {
|
|
373
|
+
steps.push({ kind: "queue", name: queue, args: ["queues", "create", queue] });
|
|
374
|
+
}
|
|
228
375
|
return steps;
|
|
229
376
|
}
|
|
230
377
|
|
|
@@ -255,8 +402,12 @@ async function cmdDeploy(cwd, flags, rest) {
|
|
|
255
402
|
const wranglerPath = join(cwd, "wrangler.jsonc");
|
|
256
403
|
if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
|
|
257
404
|
|
|
258
|
-
// Regenerate so the shipped worker/schema always match the config
|
|
259
|
-
|
|
405
|
+
// Regenerate so the shipped worker/schema always match the config — but NOT
|
|
406
|
+
// on a dry run. This used to sit above the `--dry-run` guard, so a command
|
|
407
|
+
// that ends by printing "(dry run — nothing executed)" had already rewritten
|
|
408
|
+
// three files and silently discarded any local edits to them. Probing the
|
|
409
|
+
// plan on a working branch is exactly when someone has local edits.
|
|
410
|
+
if (!dryRun) await cmdGenerate(cwd, flags, { quiet: true });
|
|
260
411
|
|
|
261
412
|
let wrangler = readFileSync(wranglerPath, "utf8");
|
|
262
413
|
const facts = readWranglerFacts(wrangler);
|
|
@@ -301,8 +452,11 @@ async function cmdDeploy(cwd, flags, rest) {
|
|
|
301
452
|
for (const s of plan) {
|
|
302
453
|
out(`\n▸ wrangler ${s.args.join(" ")}`);
|
|
303
454
|
const res = runInherit(s.args);
|
|
304
|
-
// R2 bucket
|
|
305
|
-
|
|
455
|
+
// R2 bucket + queue creates are idempotent-ish (an existing one errors) —
|
|
456
|
+
// tolerate those so a re-run of `astroid deploy` isn't a hard stop.
|
|
457
|
+
if (res.status !== 0 && s.kind !== "r2" && s.kind !== "queue") {
|
|
458
|
+
fail(`Provisioning failed at: wrangler ${s.args.join(" ")}`);
|
|
459
|
+
}
|
|
306
460
|
|
|
307
461
|
if (s.kind === "d1") {
|
|
308
462
|
const id = lookupId(wranglerBin, cwd, ["d1", "list", "--json"], (rows) => rows.find((r) => r.name === s.name)?.uuid);
|
|
@@ -342,6 +496,28 @@ async function cmdDeploy(cwd, flags, rest) {
|
|
|
342
496
|
}
|
|
343
497
|
|
|
344
498
|
// --- helpers ---------------------------------------------------------------
|
|
499
|
+
/**
|
|
500
|
+
* Minimal KEY=VALUE reader for .dev.vars. Not a dotenv implementation — doctor
|
|
501
|
+
* only needs to know whether a value is absent, empty, or the placeholder, so
|
|
502
|
+
* expansion, multiline values, and `export` prefixes are out of scope. Quotes
|
|
503
|
+
* are stripped because wrangler accepts them and a quoted placeholder must
|
|
504
|
+
* still read as a placeholder.
|
|
505
|
+
*/
|
|
506
|
+
function parseDotEnv(text) {
|
|
507
|
+
const vars = {};
|
|
508
|
+
for (const line of text.split("\n")) {
|
|
509
|
+
const trimmed = line.trim();
|
|
510
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
511
|
+
const eq = trimmed.indexOf("=");
|
|
512
|
+
if (eq < 1) continue;
|
|
513
|
+
const key = trimmed.slice(0, eq).trim();
|
|
514
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
515
|
+
if (value.length >= 2 && /^(".*"|'.*')$/s.test(value)) value = value.slice(1, -1);
|
|
516
|
+
vars[key] = value;
|
|
517
|
+
}
|
|
518
|
+
return vars;
|
|
519
|
+
}
|
|
520
|
+
|
|
345
521
|
function out(s) {
|
|
346
522
|
process.stdout.write(`${s}\n`);
|
|
347
523
|
}
|
|
@@ -362,7 +538,7 @@ Usage:
|
|
|
362
538
|
astroid build [...astro args] regenerate, then run \`astro build\`
|
|
363
539
|
astroid deploy [--dry-run] [--yes] [--local] provision bindings + migrate + secrets + deploy
|
|
364
540
|
|
|
365
|
-
New project:
|
|
541
|
+
New project: pnpm create astroid@latest
|
|
366
542
|
`;
|
|
367
543
|
|
|
368
544
|
async function main() {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AstroidConfig } from "../config.js";
|
|
2
|
+
/** The Analytics Engine dataset binding name. */
|
|
3
|
+
export declare const ASTROID_VITALS_BINDING = "VITALS";
|
|
4
|
+
/** The dataset name, derived from the project key so two Astroid sites on one
|
|
5
|
+
* account don't write into the same table. */
|
|
6
|
+
export declare function astroidVitalsDataset(config: AstroidConfig): string;
|
|
7
|
+
/**
|
|
8
|
+
* Credentials the CWV READ-BACK needs — not the collection.
|
|
9
|
+
*
|
|
10
|
+
* The Analytics Engine SQL API is account-scoped and has no binding, so a token
|
|
11
|
+
* is unavoidable. Both are required together: an account id without a token (or
|
|
12
|
+
* vice versa) can't query, so partial provisioning reads as dormant exactly like
|
|
13
|
+
* every other Astroid module.
|
|
14
|
+
*/
|
|
15
|
+
export declare const ASTROID_VITALS_SECRET_NAMES: readonly ["CF_ACCOUNT_ID", "CF_API_TOKEN"];
|
|
16
|
+
/**
|
|
17
|
+
* The public beacon, as a static `public/vitals.js`.
|
|
18
|
+
*
|
|
19
|
+
* A FILE rather than an inline script, and that is a CSP decision, not a style
|
|
20
|
+
* one: Astro hashes the scripts it processes into `script-src`, and an
|
|
21
|
+
* `is:inline` script carrying generated content cannot be hashed — it would be
|
|
22
|
+
* blocked. Served from `public/`, it is same-origin and covered by
|
|
23
|
+
* `script-src 'self'` with no policy change at all.
|
|
24
|
+
*/
|
|
25
|
+
export declare function generateAstroidVitalsBeacon(config: AstroidConfig, beacon: string): {
|
|
26
|
+
path: string;
|
|
27
|
+
contents: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The health scan's CWV read-back, as source for the generated worker.
|
|
31
|
+
*
|
|
32
|
+
* Returns `undefined` — so the badge stays "not measured yet" — when the API
|
|
33
|
+
* credentials aren't set, the query fails, or there is no field data yet. Never
|
|
34
|
+
* throws: a failed vitals query must not take down the rest of the health scan,
|
|
35
|
+
* which is mostly cheap COUNTs that have nothing to do with it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function generateAstroidCwvQuery(config: AstroidConfig): string[];
|