astroidjs 0.18.0 → 0.20.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 (44) hide show
  1. package/README.md +6 -7
  2. package/bin/astroid.mjs +51 -11
  3. package/dist/commerce/adapters.js +3 -3
  4. package/dist/commerce/checkout.js +1 -1
  5. package/dist/commerce/mirror.js +5 -4
  6. package/dist/commerce/roles.js +3 -3
  7. package/dist/commerce/secrets.d.ts +7 -0
  8. package/dist/commerce/secrets.js +12 -3
  9. package/dist/config.d.ts +85 -16
  10. package/dist/config.js +111 -8
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/portal/guard.js +1 -1
  14. package/dist/project/generate.js +96 -52
  15. package/dist/project/index.d.ts +1 -0
  16. package/dist/project/index.js +1 -0
  17. package/dist/project/migrations.d.ts +26 -0
  18. package/dist/project/migrations.js +78 -0
  19. package/dist/project/scaffold.d.ts +6 -0
  20. package/dist/project/scaffold.js +33 -11
  21. package/dist/queues/index.d.ts +1 -1
  22. package/dist/queues/index.js +1 -1
  23. package/dist/queues/messages.d.ts +6 -0
  24. package/dist/queues/messages.js +17 -2
  25. package/dist/queues/scaffold.js +5 -1
  26. package/dist/schema/collections.d.ts +6 -0
  27. package/dist/schema/collections.js +14 -2
  28. package/dist/schema/framework.js +1 -1
  29. package/dist/schema/generate.js +48 -4
  30. package/dist/security/index.d.ts +1 -1
  31. package/dist/security/index.js +1 -1
  32. package/dist/security/rate-rules.d.ts +9 -2
  33. package/dist/security/rate-rules.js +46 -22
  34. package/dist/seo/routes.js +3 -2
  35. package/dist/shape.d.ts +6 -0
  36. package/dist/shape.js +14 -0
  37. package/dist/status.js +22 -10
  38. package/dist/worker/generate.js +142 -10
  39. package/dist/workflow/advance.js +1 -1
  40. package/dist/workflow/config.js +1 -1
  41. package/package.json +3 -3
  42. package/src/components/Credit.astro +70 -0
  43. package/src/components/StageBar.astro +1 -1
  44. package/src/components/media-meta.ts +1 -1
package/README.md CHANGED
@@ -35,17 +35,16 @@ project:** every site Astroid targets serves a single brand from a single deploy
35
35
  so the config describes one brand, not an array. What actually multiplexes is
36
36
  _editors_ (Louise's org plugin) and _audiences_ (a gated portal beside the public
37
37
  site)—both options on the one brand. The vocabulary is drawn from the real
38
- sites Astroid targets: a storefront (coracle.coffee), a wholesale front
39
- (ghostfire.coffee), an artist portfolio (themidwestartist.com), and a plain
40
- marketing baseline (louise-web).
38
+ sites Astroid was built from: a storefront, a wholesale front, an artist
39
+ portfolio, and a plain marketing baseline.
41
40
 
42
41
  ```ts
43
42
  import { defineAstroid } from "astroidjs";
44
43
 
45
44
  export default defineAstroid({
46
- key: "coracle",
45
+ key: "example",
47
46
  archetype: "storefront",
48
- theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
47
+ theme: { name: "Example Organization", colors: { brand: "#5b4bff" } },
49
48
  sections: ["hero", "banner", "productGrid", "locationHours", "contact"],
50
49
  commerce: { provider: "square" },
51
50
  deploy: { platform: "cloudflare" },
@@ -56,9 +55,9 @@ A portfolio with a gated client area, for contrast:
56
55
 
57
56
  ```ts
58
57
  export default defineAstroid({
59
- key: "megbowen",
58
+ key: "example-studio",
60
59
  archetype: "portfolio",
61
- theme: { name: "Meg Bowen Studio", colors: { brand: "#2b2b2b" } },
60
+ theme: { name: "Example Studio", colors: { brand: "#2b2b2b" } },
62
61
  sections: ["hero", "gallery", "aboutIntro", "contact"],
63
62
  portal: { enabled: true },
64
63
  // The masters ARE the product here: 40 MB camera files upload once and only
package/bin/astroid.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { execFileSync, spawn, spawnSync } from "node:child_process";
19
19
  import { randomBytes } from "node:crypto";
20
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
21
21
  import { createRequire } from "node:module";
22
22
  import { dirname, isAbsolute, join, resolve } from "node:path";
23
23
  import { createInterface } from "node:readline/promises";
@@ -87,6 +87,22 @@ async function loadConfig(cwd, explicit) {
87
87
  return { config, path };
88
88
  }
89
89
 
90
+ /**
91
+ * The scaffold files with each migration placed in the `DB` binding's
92
+ * `migrations_dir` and numbered past the site's own migrations. Without this a
93
+ * site whose migrations live elsewhere got files Wrangler never applied.
94
+ */
95
+ async function scaffoldFilesFor(cwd, files) {
96
+ const { astroidMigrationsDir, resolveAstroidScaffoldPaths } = await import(GENERATORS_URL);
97
+ const wranglerPath = join(cwd, "wrangler.jsonc");
98
+ const migrationsDir = astroidMigrationsDir(
99
+ existsSync(wranglerPath) ? readFileSync(wranglerPath, "utf8") : null,
100
+ );
101
+ const dirPath = join(cwd, migrationsDir);
102
+ const existing = existsSync(dirPath) ? readdirSync(dirPath) : [];
103
+ return resolveAstroidScaffoldPaths(files, { migrationsDir, existing });
104
+ }
105
+
90
106
  // --- commands --------------------------------------------------------------
91
107
  async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
92
108
  const {
@@ -123,7 +139,7 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
123
139
  // regenerated a project that couldn't resolve its own imports. Completing the
124
140
  // config change is what makes "one typed config" true.
125
141
  const created = [];
126
- for (const file of generateAstroidScaffoldFiles(config)) {
142
+ for (const file of await scaffoldFilesFor(cwd, generateAstroidScaffoldFiles(config))) {
127
143
  const abs = join(cwd, file.path);
128
144
  const exists = existsSync(abs);
129
145
  if (file.apply === "append-once") {
@@ -156,6 +172,7 @@ async function cmdDoctor(cwd, flags) {
156
172
  generateAstroidScaffoldFiles,
157
173
  astroidUsesQueues,
158
174
  astroidCrons,
175
+ astroidHasEditor,
159
176
  checkWranglerPreviews,
160
177
  astroidRunsMigrations,
161
178
  migrationsOwnershipError,
@@ -196,7 +213,7 @@ async function cmdDoctor(cwd, flags) {
196
213
  // that names a module whose seam was never written produces a project that
197
214
  // cannot resolve its own imports. This is precisely the state that used to
198
215
  // report "healthy" with two warnings and exit 0.
199
- for (const file of generateAstroidScaffoldFiles(config)) {
216
+ for (const file of await scaffoldFilesFor(cwd, generateAstroidScaffoldFiles(config))) {
200
217
  if (file.apply === "append-once") continue; // accumulated, not owned—see below
201
218
  if (existsSync(join(cwd, file.path))) ok(`${file.path} present`);
202
219
  else err(`${file.path} is missing (required by your config) — run \`astroid generate\`.`);
@@ -219,9 +236,15 @@ async function cmdDoctor(cwd, flags) {
219
236
  //
220
237
  // So check every binding the GENERATED code actually dereferences, not just
221
238
  // the three the baseline happens to have.
239
+ // An app with no editor (`editor: false`) uses none of the editor's
240
+ // bindings, so it isn't held to them: no draft buffer, no media bucket, and
241
+ // no mail unless its portal sends password resets.
242
+ const editor = astroidHasEditor(config);
222
243
  const requiredBindings = [
223
244
  { name: "RL", what: "KV namespace", why: "the rate limiter in src/middleware.ts" },
224
- { name: "DRAFTS", what: "KV namespace", why: "the autosave draft buffer" },
245
+ ...(editor
246
+ ? [{ name: "DRAFTS", what: "KV namespace", why: "the autosave draft buffer" }]
247
+ : []),
225
248
  ...(astroidUsesQueues(config)
226
249
  ? [
227
250
  {
@@ -247,17 +270,24 @@ async function cmdDoctor(cwd, flags) {
247
270
  // it: the magic link is console-logged in dev and EMAILED in production, so
248
271
  // a missing binding is a site nobody can sign in to—and it fails only once
249
272
  // deployed, which is the one place nothing in this repo exercises.
250
- if (/"send_email"\s*:/.test(w)) ok("wrangler: Email Sending `EMAIL` binding present");
251
- else
273
+ // An app with no editor and no portal signs nobody in, so it sends no mail.
274
+ const sendsMail = editor || Boolean(config.portal?.enabled);
275
+ if (sendsMail && /"send_email"\s*:/.test(w))
276
+ ok("wrangler: Email Sending `EMAIL` binding present");
277
+ else if (sendsMail)
252
278
  err(
253
- "wrangler.jsonc has no `send_email` binding, but production sign-in emails the " +
254
- 'magic link (it is only console-logged in dev). Add: "send_email": [{ "name": "EMAIL" }]',
279
+ editor
280
+ ? "wrangler.jsonc has no `send_email` binding, but production sign-in emails the " +
281
+ 'magic link (it is only console-logged in dev). Add: "send_email": [{ "name": "EMAIL" }]'
282
+ : "wrangler.jsonc has no `send_email` binding, but the portal emails password resets " +
283
+ 'in production (they are only console-logged in dev). Add: "send_email": [{ "name": "EMAIL" }]',
255
284
  );
256
285
 
257
286
  if (hasBinding("DB")) ok("wrangler: D1 `DB` binding present");
258
287
  else err("wrangler.jsonc has no D1 `DB` binding.");
259
- if (hasBinding("MEDIA")) ok("wrangler: R2 `MEDIA` binding present");
260
- else err("wrangler.jsonc has no R2 `MEDIA` binding.");
288
+ // An app with no editor has no media library, so no bucket to hold it.
289
+ if (editor && hasBinding("MEDIA")) ok("wrangler: R2 `MEDIA` binding present");
290
+ else if (editor) err("wrangler.jsonc has no R2 `MEDIA` binding.");
261
291
  if (/"main"\s*:\s*"src\/worker\.ts"/.test(w)) ok("wrangler: `main` → src/worker.ts");
262
292
  else warn("wrangler.jsonc `main` does not point at src/worker.ts.");
263
293
 
@@ -271,7 +301,17 @@ async function cmdDoctor(cwd, flags) {
271
301
  // JSON.parse (comments/trailing commas), matching the binding checks above.
272
302
  const expectedCrons = astroidCrons(config);
273
303
  const cronsMatch = w.match(/"crons"\s*:\s*\[([^\]]*)\]/);
274
- if (!cronsMatch) {
304
+ const declaredAny = cronsMatch && /"[^"]+"/.test(cronsMatch[1]);
305
+ if (expectedCrons.length === 0) {
306
+ // Nothing scheduled, so the generated worker has no `scheduled` handler,
307
+ // and a declared trigger would fail every time it fired.
308
+ if (declaredAny)
309
+ warn(
310
+ "wrangler.jsonc declares `triggers.crons`, but nothing in your config is scheduled, " +
311
+ "so the generated worker has no `scheduled` handler for them. Remove the triggers.",
312
+ );
313
+ else ok("wrangler: no crons, and nothing is scheduled");
314
+ } else if (!cronsMatch) {
275
315
  err(
276
316
  "wrangler.jsonc has no `triggers.crons`, but the generated `scheduled` handler " +
277
317
  `dispatches on ${expectedCrons.map((c) => `"${c}"`).join(", ")}. ` +
@@ -2,9 +2,9 @@
2
2
  //
3
3
  // Provider → `CatalogItem` normalizers.
4
4
  //
5
- // This is the file the whole module exists for. themidwestartist.com's loader
6
- // says it outright: coracle runs the same helper over Square, "only the
7
- // content/repo reads differ—issue: repo drift." Two sites, one intent, two
5
+ // This is the file the whole module exists for. One client site's loader says it
6
+ // outright: another site runs the same helper over Square, and only the content
7
+ // and repository reads differ, so the copies drift. Two sites, one intent, two
8
8
  // hand-written translations that drifted apart. The translation is mechanical,
9
9
  // so it belongs here once.
10
10
  //
@@ -3,7 +3,7 @@
3
3
  // Server-authoritative checkout.
4
4
  //
5
5
  // A cart arrives from the browser, so every number in it is a claim, not a fact.
6
- // The rule this encodes—taken from coracle.coffee's working checkout—is that
6
+ // The rule this encodes—taken from a client site's working checkout—is that
7
7
  // the client's price is a **staleness check**, never an input to the charge:
8
8
  // look the price up server-side, and if it disagrees with what the customer was
9
9
  // shown, refuse rather than silently charging a different amount. Refusing is
@@ -12,7 +12,7 @@
12
12
  // What the sites did NOT agree on is how much to store, and it turns out to be
13
13
  // one primitive with two settings rather than two designs:
14
14
  //
15
- // mirror: pulled + owned columns both live in D1 (themidwestartist.com).
15
+ // mirror: pulled + owned columns both live in D1.
16
16
  // Reads are one local query. The catalog can be stale between syncs.
17
17
  // overlay: only the owned columns live in D1, keyed by the provider's id.
18
18
  // The catalog is read live from the provider and joined at read
@@ -20,9 +20,10 @@
20
20
  // (cache accordingly).
21
21
  // live: Astroid manages NO catalog table at all: the catalog is read live
22
22
  // from the provider (cached), and any owner-side overlay table is
23
- // the SITE's own (coracle.coffee's `product_display_meta`, declared
24
- // in schema.site.ts and joined in the site's loader). Use when the
25
- // existing overlay shape predates Astroid and must be preserved 1:1.
23
+ // the SITE's own (for example, a `product_display_meta` table
24
+ // declared in schema.site.ts and joined in the site's loader).
25
+ // Use when the existing overlay shape predates Astroid and must
26
+ // be preserved 1:1.
26
27
  //
27
28
  // `overlay` is just `mirror` with an empty pulled set; `live` emits neither table
28
29
  // nor migration. One generator serves all three and a project switches by one word.
@@ -9,9 +9,9 @@
9
9
  // happens to do both. A single `CommerceProvider` abstraction that assumed
10
10
  // catalog + checkout would therefore have a permanent hole wherever Stripe sits.
11
11
  //
12
- // That's not hypothetical. themidwestartist.com runs Stripe for **invoicing**
13
- // (commissions, originals) alongside Fourthwall for the **storefront**
14
- // (merch)—two providers, one site, each doing the half it can do.
12
+ // A site that invoices through one provider (commissions, originals) and runs
13
+ // its storefront through another (merch) needs exactly this: two providers,
14
+ // one site, each doing the half it can do.
15
15
  //
16
16
  // So a project assigns providers to roles, and Astroid validates the assignment
17
17
  // against what each provider's client can actually serve.
@@ -50,6 +50,13 @@ export declare function commerceProviderCredentials(provider: CommerceProvider,
50
50
  * `.dev.vars` would be a bug rather than a redundancy.
51
51
  */
52
52
  export declare function commerceSecretNames(commerce: CommerceConfig | undefined): string[];
53
+ /**
54
+ * The webhook signing secret a provider needs, or none when the project runs no
55
+ * pipeline (`commerce.pipeline: false`). With no receiver there is nothing to
56
+ * verify, so requiring the secret would hold checkout dormant for a value no
57
+ * code reads.
58
+ */
59
+ export declare function commerceProviderWebhookSecrets(provider: CommerceProvider, commerce: CommerceConfig | undefined): readonly string[];
53
60
  /** One provider's resolved gate. */
54
61
  export interface ProviderStatus {
55
62
  provider: CommerceProvider;
@@ -101,19 +101,28 @@ export function commerceProviderCredentials(provider, commerce) {
101
101
  export function commerceSecretNames(commerce) {
102
102
  const names = astroidCommerceProviders(commerce).flatMap((provider) => [
103
103
  ...commerceProviderCredentials(provider, commerce),
104
- COMMERCE_PROVIDER_SECRETS[provider].webhook,
104
+ ...commerceProviderWebhookSecrets(provider, commerce),
105
105
  ]);
106
106
  return [...new Set(names)];
107
107
  }
108
+ /**
109
+ * The webhook signing secret a provider needs, or none when the project runs no
110
+ * pipeline (`commerce.pipeline: false`). With no receiver there is nothing to
111
+ * verify, so requiring the secret would hold checkout dormant for a value no
112
+ * code reads.
113
+ */
114
+ export function commerceProviderWebhookSecrets(provider, commerce) {
115
+ return commerce?.pipeline === false ? [] : [COMMERCE_PROVIDER_SECRETS[provider].webhook];
116
+ }
108
117
  /** Read one provider's secrets off an env-shaped record. */
109
118
  async function resolveProvider(provider, roles, env, commerce) {
110
- const spec = COMMERCE_PROVIDER_SECRETS[provider];
111
119
  const pick = (names) => Object.fromEntries(names.map((n) => [n, env[n]]));
112
120
  const [credentials, webhook] = await Promise.all([
113
121
  // Config-aware: a multi-location project must not be held dormant waiting
114
122
  // for a SQUARE_LOCATION_ID it will never legitimately have.
115
123
  resolveModuleSecrets(pick(commerceProviderCredentials(provider, commerce))),
116
- resolveModuleSecrets(pick([spec.webhook])),
124
+ // Empty without a pipeline, which resolves as configured: nothing to verify.
125
+ resolveModuleSecrets(pick(commerceProviderWebhookSecrets(provider, commerce))),
117
126
  ]);
118
127
  return {
119
128
  provider,
package/dist/config.d.ts CHANGED
@@ -8,8 +8,8 @@ import type { PwaConfig } from "./pwa/generate.js";
8
8
  * The starting shape the front-end takes. Not a fork—each archetype is a preset
9
9
  * of defaults (which sections/modules are on, nav shape) that the site then tunes.
10
10
  * `marketing` = the lean brochure floor (louise-web, no commerce); `storefront` =
11
- * DTC shop (coracle); `wholesale` = B2B/private-label (ghostfire); `portfolio` =
12
- * gallery + prints + client portal (megbowen).
11
+ * DTC shop; `wholesale` = B2B/private-label; `portfolio` = gallery + prints +
12
+ * client portal.
13
13
  */
14
14
  export type Archetype = "marketing" | "storefront" | "wholesale" | "portfolio";
15
15
  /**
@@ -56,7 +56,7 @@ export declare const ASTROID_ARCHETYPE_SECTIONS: Record<Archetype, SectionKind[]
56
56
  * deploy and notice the absence.
57
57
  *
58
58
  * They are removed rather than left as TODOs. `orderTracking` in particular has
59
- * a real implementation waiting—`src/workflow/` is the ghostfire order tracker,
59
+ * a real implementation waiting—`src/workflow/` is a client site's order tracker,
60
60
  * generalized—but it is reached through `defineWorkflow`, not this flag, and
61
61
  * pretending otherwise is what made the flag misleading. Re-add each one in the
62
62
  * change that wires it.
@@ -152,8 +152,9 @@ export interface CommerceConfig {
152
152
  storefront?: CommerceProvider;
153
153
  /**
154
154
  * Invoices for work that isn't a catalog item—commissions, originals.
155
- * Independent of `storefront`: themidwestartist.com runs Stripe here and
156
- * Fourthwall as the storefront, because neither can do the other's job.
155
+ * Independent of `storefront`: a site can invoice through one provider (say,
156
+ * Stripe) and run its storefront through another (say, Fourthwall), because
157
+ * neither can do the other's job.
157
158
  */
158
159
  invoicing?: CommerceProvider;
159
160
  /**
@@ -161,7 +162,7 @@ export interface CommerceConfig {
161
162
  * counter or a market stall rather than through the site's own cart.
162
163
  *
163
164
  * Separate from `storefront` because they are genuinely different jobs and a
164
- * site commonly runs both. themidwestartist.com sells print-on-demand merch
165
+ * site commonly runs both. An artist's site might sell print-on-demand merch
165
166
  * through Fourthwall (`storefront`) while originals and self-stocked prints
166
167
  * live in Square (`pos`) across several shops and galleries—one catalog per
167
168
  * rail, neither able to do the other's job.
@@ -174,6 +175,18 @@ export interface CommerceConfig {
174
175
  catalog?: CatalogMirrorConfig;
175
176
  /** Square-specific options. Only meaningful when Square fills some role. */
176
177
  square?: SquareCommerceConfig;
178
+ /**
179
+ * Whether this project runs the commerce pipeline: the webhook receivers, the
180
+ * queue consumer that processes them, and the hourly catalog re-sync. Default
181
+ * `true`.
182
+ *
183
+ * Set `false` for a project that only takes payments while another project,
184
+ * or another Worker in the same repository, runs the pipeline against the
185
+ * same account. It keeps what a checkout needs, the provider's CSP origins,
186
+ * the checkout rate rule, and the checkout route, and drops the rest, along
187
+ * with the webhook signing secret nothing would verify.
188
+ */
189
+ pipeline?: boolean;
177
190
  }
178
191
  export interface SquareCommerceConfig {
179
192
  /**
@@ -194,8 +207,9 @@ export interface SquareCommerceConfig {
194
207
  export interface QueuesConfig {
195
208
  /**
196
209
  * Force the queue consumer + cron on or off. Defaults to on whenever
197
- * `commerce` is configured: a commerce provider means webhooks, and a webhook
198
- * you process inline is a webhook you drop when the provider times out.
210
+ * `commerce` is configured with its pipeline: a commerce provider means
211
+ * webhooks, and a webhook you process inline is a webhook you drop when the
212
+ * provider times out.
199
213
  */
200
214
  enabled?: boolean;
201
215
  /**
@@ -323,9 +337,9 @@ export interface TenancyConfig {
323
337
  * sign-in) instead of JSON, and every data load on that host silently fails
324
338
  * while the same code works on the apex.
325
339
  *
326
- * That is not hypothetical—it is why this default exists (found on
327
- * themidwestartist.com's studio, where the whole admin app loaded and then
328
- * fetched nothing).
340
+ * That is not hypothetical—it is why this default exists (found on a client
341
+ * site's studio host, where the whole admin app loaded and then fetched
342
+ * nothing).
329
343
  *
330
344
  * Set `[]` to rewrite everything, or add prefixes for other host-agnostic
331
345
  * surfaces (`/_actions`, `/webhooks`). Matching is prefix-based on a path
@@ -407,8 +421,8 @@ export interface SettingsConfig {
407
421
  * on top of (or, with `columns: []`, instead of) Astroid's base columns. The
408
422
  * generated `settingsRoute` + Action accept these; the Settings panel writes
409
423
  * them through the `settingsExtension` groups a site supplies to
410
- * `mountSettings`. A site with a rich settings shape (coracle's footer columns,
411
- * hours table, ui strings, shop/order config) lists their top-level keys here.
424
+ * `mountSettings`. A site with a rich settings shape (footer columns, an hours
425
+ * table, UI strings, shop/order config) lists their top-level keys here.
412
426
  */
413
427
  customKeys?: string[];
414
428
  /** Extra media-library image keys beyond the base logo/favicon/OG defaults—*
@@ -456,7 +470,22 @@ export interface PagesConfig {
456
470
  * clamping a title, or filling a new page's defaults.
457
471
  */
458
472
  hooks?: boolean;
473
+ /**
474
+ * Reserved slugs this site serves as pages. `contact` and `login` are
475
+ * reserved because a new scaffold has file routes there, and `work` because
476
+ * a portfolio has its gallery there. A site without that file, whose page at
477
+ * the path comes from its own catch-all route, lists the slug here so the
478
+ * Pages route accepts it. Only those three can be allowed
479
+ * ({@link ASTROID_SCAFFOLD_ROUTE_SLUGS}): the platform serves the other
480
+ * reserved slugs, such as `api` and `sitemap.xml`, before any page.
481
+ */
482
+ allowSlugs?: string[];
459
483
  }
484
+ /**
485
+ * The reserved slugs that come from a scaffolded file route rather than the
486
+ * platform, so a site without that file can allow them with `pages.allowSlugs`.
487
+ */
488
+ export declare const ASTROID_SCAFFOLD_ROUTE_SLUGS: readonly string[];
460
489
  export interface StatusConfig {
461
490
  /**
462
491
  * Add the site's own checks to the public status route, from the
@@ -481,10 +510,32 @@ export interface DeployConfig {
481
510
  */
482
511
  migrations?: boolean;
483
512
  }
513
+ /**
514
+ * A small "Site by …" line the footer renders for the agency that built the
515
+ * site. A site fact, so it has no default: omit it and nothing renders.
516
+ */
517
+ export interface CreditConfig {
518
+ /** Who built the site, for example `"Example Organization"`. */
519
+ name: string;
520
+ /** Where the credit links, as an absolute `http` or `https` URL. */
521
+ href: string;
522
+ /**
523
+ * An optional mark shown before the name: a root-relative path (for example
524
+ * `"/credit-mark.svg"`) or an `https:` URL. It's drawn as a mask filled with
525
+ * the text color, so one single-color SVG works on every theme. Only its
526
+ * shape counts; its own fill is ignored.
527
+ */
528
+ logo?: string;
529
+ /** The link's `rel`, for example `"noopener"` or `"noopener nofollow"`. The
530
+ * site decides; omitted, the link carries none. */
531
+ rel?: string;
532
+ /** The words before the name. Default `"Site by"`. */
533
+ label?: string;
534
+ }
484
535
  export interface AstroidConfig {
485
536
  /**
486
537
  * Stable project slug—the worker/D1/R2 base name and default subdomain (for example,
487
- * `"coracle"`). Required and non-empty; it drives the generated binding names.
538
+ * `"example"`). Required and non-empty; it drives the generated binding names.
488
539
  */
489
540
  key: string;
490
541
  /** Hostnames this site serves (prod + preview), for custom-domain routes. */
@@ -497,6 +548,21 @@ export interface AstroidConfig {
497
548
  tenancy?: TenancyConfig;
498
549
  /** Starting shape; sets section/module/nav defaults the site can override. */
499
550
  archetype: Archetype;
551
+ /**
552
+ * Whether the project has a Louise editor. Default `true`.
553
+ *
554
+ * Set `false` for an app with no pages to edit, such as an order app whose
555
+ * menu comes from a commerce provider and whose few settings another site's
556
+ * editor owns. The generated worker and middleware then carry no editor
557
+ * routes and no sign-in, the schema carries no content tables, and
558
+ * `wrangler.jsonc` binds no draft buffer, media bucket, or AI. What stays is
559
+ * the rate limiter, the CSP, the security headers, the public status route,
560
+ * and the `portal`, `pwa`, `commerce`, and `tenancy` modules.
561
+ *
562
+ * `defineAstroid` refuses every option that configures the editor
563
+ * alongside it, such as `sections` or `media`, rather than ignore it.
564
+ */
565
+ editor?: boolean;
500
566
  /** The single brand's theme (display name + color tokens + font). */
501
567
  theme: Theme;
502
568
  /** The editable home page, top to bottom. Omit to take the archetype default. */
@@ -505,7 +571,7 @@ export interface AstroidConfig {
505
571
  * A site-provided section catalog that REPLACES the built-in one for
506
572
  * SERVER-side validation + sanitization of `pages.sections` (the generated
507
573
  * pages route + versions route). A site with bespoke section designs—its own
508
- * `.astro` components and field defs (coracle's 13 sections)—registers them
574
+ * `.astro` components and field defs (a dozen or more sections)—registers them
509
575
  * here so writes to its custom `_type`s validate instead of 422-ing against the
510
576
  * built-in vocabulary. The on-canvas editor already uses the site's catalog
511
577
  * (its `mountSections` call passes it); this closes the server half so both
@@ -561,11 +627,14 @@ export interface AstroidConfig {
561
627
  * Force the contact form + `inquiries` table on or off. Omit to detect from
562
628
  * the config (a `contact` section, or a wholesale-inquiry module). Set `true`
563
629
  * when a bespoke section captures inquiries under a name Astroid can't see
564
- * (coracle's custom `contactForm`); set `false` to suppress it entirely.
630
+ * (for example, a custom `contactForm` section); set `false` to suppress it
631
+ * entirely.
565
632
  */
566
633
  inquiries?: boolean;
567
634
  /** Installable-app settings. Only read when `modules` includes `"pwa"`. */
568
635
  pwa?: PwaConfig;
636
+ /** The agency credit `<Credit>` renders in the footer. Omit for none. */
637
+ credit?: CreditConfig;
569
638
  deploy?: DeployConfig;
570
639
  }
571
640
  export declare function defineAstroid(config: AstroidConfig): AstroidConfig;
package/dist/config.js CHANGED
@@ -8,8 +8,8 @@
8
8
  // the Louise wiring (worker routes, middleware, Drizzle schema, theme tokens) a
9
9
  // site would otherwise hand-write per repo.
10
10
  //
11
- // ONE brand per project. Every site Astroid targets (coracle.coffee,
12
- // ghostfire.coffee, themidwestartist.com, louise-web) serves a single brand from a
11
+ // ONE brand per project. Every site Astroid targets (the client sites its
12
+ // patterns came from, and louise-web) serves a single brand from a
13
13
  // single deploy. The axis that genuinely multiplexes is *editors* (Louise's org
14
14
  // plugin, #100) and *audiences*—a gated portal alongside the public site, or a
15
15
  // per-merchant storefront on its own subdomain (`tenancy`)—not brands. So all
@@ -22,9 +22,9 @@
22
22
  // a second project.
23
23
  //
24
24
  // The vocabulary below is not invented: `Archetype`, `SectionKind`, and
25
- // `ModuleKind` are extracted from the real sites Astroid targets—a storefront
26
- // (coracle), a wholesale front (ghostfire), an artist portfolio (megbowen), and a
27
- // plain marketing baseline (louise-web).
25
+ // `ModuleKind` are extracted from the real sites Astroid targets—a storefront,
26
+ // a wholesale front, an artist portfolio, and a plain marketing baseline
27
+ // (louise-web).
28
28
  import { assertAuthIsolation } from "./auth/index.js";
29
29
  import { assertCommerceRoles } from "./commerce/roles.js";
30
30
  import { AstroidConfigError } from "./errors.js";
@@ -34,6 +34,7 @@ import { AstroidConfigError } from "./errors.js";
34
34
  // type-only, so the cycle erases at build and nothing circular exists at runtime.
35
35
  // It is also dependency-free, so `create-astroid`'s graph is unchanged.
36
36
  import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "./queues/messages.js";
37
+ import { astroidHasEditor } from "./shape.js";
37
38
  /**
38
39
  * Each archetype's default home-page sections.
39
40
  *
@@ -55,6 +56,11 @@ export const ASTROID_ARCHETYPE_SECTIONS = {
55
56
  wholesale: ["hero", "featureGrid", "aboutIntro", "contact"],
56
57
  portfolio: ["hero", "gallery", "aboutIntro", "contact"],
57
58
  };
59
+ /**
60
+ * The reserved slugs that come from a scaffolded file route rather than the
61
+ * platform, so a site without that file can allow them with `pages.allowSlugs`.
62
+ */
63
+ export const ASTROID_SCAFFOLD_ROUTE_SLUGS = ["contact", "login", "work"];
58
64
  /**
59
65
  * Define an Astroid project. An identity function in the shape of Astro's
60
66
  * `defineConfig`: it returns the config verbatim with full type-checking +
@@ -64,9 +70,9 @@ export const ASTROID_ARCHETYPE_SECTIONS = {
64
70
  *
65
71
  * ```ts
66
72
  * export default defineAstroid({
67
- * key: "coracle",
73
+ * key: "example",
68
74
  * archetype: "storefront",
69
- * theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
75
+ * theme: { name: "Example Organization", colors: { brand: "#5b4bff" } },
70
76
  * sections: ["hero", "banner", "productGrid", "locationHours", "contact"],
71
77
  * commerce: { provider: "square" },
72
78
  * deploy: { platform: "cloudflare" },
@@ -83,6 +89,13 @@ export const ASTROID_ARCHETYPE_SECTIONS = {
83
89
  * the unreachable-trigger failure `config.crons` exists to prevent.
84
90
  */
85
91
  function assertCrons(config) {
92
+ // `queues.cron` schedules the catalog re-sync, which belongs to the pipeline.
93
+ // Without one it would be accepted and never scheduled.
94
+ if (config.commerce?.pipeline === false && typeof config.queues?.cron === "string") {
95
+ throw new AstroidConfigError("`queues.cron` schedules the catalog re-sync, which `commerce.pipeline: false` " +
96
+ "leaves to the project that runs the pipeline. Remove `queues.cron`, or use " +
97
+ "`crons` for a job of this project's own.");
98
+ }
86
99
  const crons = config.crons ?? [];
87
100
  if (crons.length === 0)
88
101
  return;
@@ -91,7 +104,7 @@ function assertCrons(config) {
91
104
  "without it the generated handler would `send` to a binding this project never creates. " +
92
105
  "Set `queues: { enabled: true }`, or drop the crons.");
93
106
  }
94
- const seen = new Map([[ASTROID_HEALTH_CRON, "the daily health scan"]]);
107
+ const seen = new Map(astroidHasEditor(config) ? [[ASTROID_HEALTH_CRON, "the daily health scan"]] : []);
95
108
  const catalog = astroidCron(config);
96
109
  if (catalog)
97
110
  seen.set(catalog, "the catalog re-sync (`queues.cron`)");
@@ -109,6 +122,20 @@ function assertCrons(config) {
109
122
  seen.set(expression, "another entry in `crons`");
110
123
  }
111
124
  }
125
+ /**
126
+ * `pages.allowSlugs` may only name a slug reserved for a scaffolded file route.
127
+ * Allowing `api` or `sitemap.xml` would let an editor save a page nobody can
128
+ * reach, which is the silent failure the reserved list exists to prevent.
129
+ */
130
+ function assertAllowSlugs(config) {
131
+ for (const slug of config.pages?.allowSlugs ?? []) {
132
+ if (!ASTROID_SCAFFOLD_ROUTE_SLUGS.includes(slug)) {
133
+ throw new AstroidConfigError(`\`pages.allowSlugs\` can't allow "${slug}". Only the slugs reserved for a scaffolded ` +
134
+ `file route can be allowed (${ASTROID_SCAFFOLD_ROUTE_SLUGS.join(", ")}); the ` +
135
+ "platform serves the others before any page.");
136
+ }
137
+ }
138
+ }
112
139
  /**
113
140
  * The tenancy misconfigurations that fail late, or not at all.
114
141
  *
@@ -177,6 +204,79 @@ function assertMediaConfig(media) {
177
204
  "would fail with an error the media route never sees.");
178
205
  }
179
206
  }
207
+ /**
208
+ * A credit that would render as a broken link or an empty mark.
209
+ *
210
+ * The logo is limited to a root-relative path or `https:` because it lands in a
211
+ * CSS `url()`: a `data:` or `javascript:` value there is at best unrenderable,
212
+ * and a relative one resolves against each page's path rather than the site.
213
+ */
214
+ function assertCredit(credit) {
215
+ if (!credit)
216
+ return;
217
+ if (!credit.name?.trim()) {
218
+ throw new AstroidConfigError("`credit.name` is required: the name the footer credits");
219
+ }
220
+ let href;
221
+ try {
222
+ href = new URL(credit.href);
223
+ }
224
+ catch {
225
+ // Reported below with the value that failed.
226
+ }
227
+ if (!href || (href.protocol !== "https:" && href.protocol !== "http:")) {
228
+ throw new AstroidConfigError(`\`credit.href\` must be an absolute http or https URL, such as "https://example.com", ` +
229
+ `but it's "${credit.href}"`);
230
+ }
231
+ const logo = credit.logo;
232
+ if (logo !== undefined &&
233
+ !(logo.startsWith("/") && !logo.startsWith("//")) &&
234
+ !logo.startsWith("https://")) {
235
+ throw new AstroidConfigError(`\`credit.logo\` must be a root-relative path such as "/credit-mark.svg" or an ` +
236
+ `https URL, but it's "${logo}"`);
237
+ }
238
+ }
239
+ /**
240
+ * The options an app with no editor can't honor. Each configures the editor, a
241
+ * table it edits, or a surface only an editor reviews, so accepting one would
242
+ * be accepting a setting nothing reads.
243
+ */
244
+ const EDITOR_ONLY_OPTIONS = [
245
+ ["sections", "the editable home page"],
246
+ ["sectionCatalog", "the page editor's sections"],
247
+ ["blockCatalog", "the page editor's blocks"],
248
+ ["media", "the media library"],
249
+ ["pages", "the editable pages"],
250
+ ["settings", "the Settings panel"],
251
+ ];
252
+ /** Modules that only work with an editor, and why. */
253
+ const EDITOR_ONLY_MODULES = {
254
+ realtime: "it syncs editors editing one page",
255
+ wholesaleInquiry: "its inquiries are reviewed in the editor",
256
+ };
257
+ function assertEditorFree(config) {
258
+ if (astroidHasEditor(config))
259
+ return;
260
+ const without = "An app with `editor: false` has no editor";
261
+ for (const [key, what] of EDITOR_ONLY_OPTIONS) {
262
+ if (config[key] !== undefined) {
263
+ throw new AstroidConfigError(`${without}, so \`${key}\` (${what}) would do nothing. Remove it, or drop \`editor: false\`.`);
264
+ }
265
+ }
266
+ if (config.inquiries === true) {
267
+ throw new AstroidConfigError(`${without} to review inquiries in, so \`inquiries: true\` would collect messages ` +
268
+ "nobody reads. Remove it, or drop `editor: false`.");
269
+ }
270
+ for (const module of [...(config.modules ?? []), ...(config.portal?.features ?? [])]) {
271
+ const why = EDITOR_ONLY_MODULES[module];
272
+ if (why) {
273
+ throw new AstroidConfigError(`${without}, so the \`${module}\` module can't work: ${why}. Remove it, or drop \`editor: false\`.`);
274
+ }
275
+ }
276
+ if (config.deploy?.mediaBase !== undefined) {
277
+ throw new AstroidConfigError(`${without} and no media library, so \`deploy.mediaBase\` would do nothing. Remove it.`);
278
+ }
279
+ }
180
280
  export function defineAstroid(config) {
181
281
  if (!config.key || config.key.trim().length === 0) {
182
282
  throw new AstroidConfigError("Astroid config requires a non-empty `key` (it names the generated worker/D1/R2 bindings)");
@@ -211,6 +311,9 @@ export function defineAstroid(config) {
211
311
  // an answer. Fail loudly, at config load, naming the workaround.
212
312
  assertCrons(config);
213
313
  assertTenancy(config);
314
+ assertAllowSlugs(config);
315
+ assertEditorFree(config);
316
+ assertCredit(config.credit);
214
317
  if (config.portal?.gated) {
215
318
  throw new AstroidConfigError("`portal.gated` is not implemented: it is accepted but wires no guard, so the site " +
216
319
  "would be fully public while appearing gated. Remove it, and gate the whole site by " +