astroidjs 0.1.2 → 0.3.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 (157) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/auth/index.d.ts +27 -0
  10. package/dist/auth/index.js +59 -0
  11. package/dist/commerce/adapters.d.ts +60 -0
  12. package/dist/commerce/adapters.js +90 -0
  13. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  14. package/dist/commerce/checkout-scaffold.js +306 -0
  15. package/dist/commerce/checkout.d.ts +72 -0
  16. package/dist/commerce/checkout.js +124 -0
  17. package/dist/commerce/index.d.ts +8 -0
  18. package/dist/commerce/index.js +9 -0
  19. package/dist/commerce/loader.d.ts +71 -0
  20. package/dist/commerce/loader.js +90 -0
  21. package/dist/commerce/mirror.d.ts +69 -0
  22. package/dist/commerce/mirror.js +214 -0
  23. package/dist/commerce/roles.d.ts +38 -0
  24. package/dist/commerce/roles.js +93 -0
  25. package/dist/commerce/secrets.d.ts +74 -0
  26. package/dist/commerce/secrets.js +129 -0
  27. package/dist/commerce/sync.d.ts +86 -0
  28. package/dist/commerce/sync.js +154 -0
  29. package/dist/components/sections.d.ts +577 -0
  30. package/dist/components/sections.js +425 -0
  31. package/dist/config.d.ts +239 -12
  32. package/dist/config.js +49 -1
  33. package/dist/email/index.d.ts +4 -0
  34. package/dist/email/index.js +5 -0
  35. package/dist/email/inquiry.d.ts +33 -0
  36. package/dist/email/inquiry.js +63 -0
  37. package/dist/email/send.d.ts +120 -0
  38. package/dist/email/send.js +196 -0
  39. package/dist/email/templates.d.ts +24 -0
  40. package/dist/email/templates.js +184 -0
  41. package/dist/email/theme.d.ts +24 -0
  42. package/dist/email/theme.js +150 -0
  43. package/dist/errors.d.ts +14 -0
  44. package/dist/errors.js +17 -0
  45. package/dist/index.d.ts +15 -0
  46. package/dist/index.js +15 -0
  47. package/dist/map/index.d.ts +3 -0
  48. package/dist/map/index.js +4 -0
  49. package/dist/map/pmtiles.d.ts +92 -0
  50. package/dist/map/pmtiles.js +130 -0
  51. package/dist/map/scaffold.d.ts +29 -0
  52. package/dist/map/scaffold.js +212 -0
  53. package/dist/map/style.d.ts +58 -0
  54. package/dist/map/style.js +154 -0
  55. package/dist/portal/config.d.ts +26 -0
  56. package/dist/portal/config.js +56 -0
  57. package/dist/portal/guard.d.ts +54 -0
  58. package/dist/portal/guard.js +64 -0
  59. package/dist/portal/index.d.ts +5 -0
  60. package/dist/portal/index.js +6 -0
  61. package/dist/portal/nav.d.ts +26 -0
  62. package/dist/portal/nav.js +35 -0
  63. package/dist/portal/scaffold.d.ts +28 -0
  64. package/dist/portal/scaffold.js +140 -0
  65. package/dist/portal/session.d.ts +36 -0
  66. package/dist/portal/session.js +86 -0
  67. package/dist/portfolio/index.d.ts +1 -0
  68. package/dist/portfolio/index.js +4 -0
  69. package/dist/portfolio/scaffold.d.ts +9 -0
  70. package/dist/portfolio/scaffold.js +93 -0
  71. package/dist/project/actions.d.ts +3 -0
  72. package/dist/project/actions.js +121 -0
  73. package/dist/project/generate.d.ts +15 -0
  74. package/dist/project/generate.js +144 -2
  75. package/dist/project/index.d.ts +2 -0
  76. package/dist/project/index.js +2 -0
  77. package/dist/project/scaffold.d.ts +29 -0
  78. package/dist/project/scaffold.js +166 -0
  79. package/dist/pwa/generate.d.ts +49 -0
  80. package/dist/pwa/generate.js +218 -0
  81. package/dist/pwa/index.d.ts +1 -0
  82. package/dist/pwa/index.js +2 -0
  83. package/dist/queues/consumer.d.ts +29 -0
  84. package/dist/queues/consumer.js +37 -0
  85. package/dist/queues/index.d.ts +4 -0
  86. package/dist/queues/index.js +5 -0
  87. package/dist/queues/messages.d.ts +60 -0
  88. package/dist/queues/messages.js +71 -0
  89. package/dist/queues/scaffold.d.ts +44 -0
  90. package/dist/queues/scaffold.js +204 -0
  91. package/dist/queues/webhook.d.ts +60 -0
  92. package/dist/queues/webhook.js +81 -0
  93. package/dist/realtime/index.d.ts +1 -0
  94. package/dist/realtime/index.js +4 -0
  95. package/dist/realtime/scaffold.d.ts +30 -0
  96. package/dist/realtime/scaffold.js +159 -0
  97. package/dist/schema/collections.d.ts +41 -7
  98. package/dist/schema/collections.js +110 -12
  99. package/dist/schema/framework.js +5 -0
  100. package/dist/schema/generate.js +17 -1
  101. package/dist/secrets.d.ts +54 -0
  102. package/dist/secrets.js +80 -0
  103. package/dist/security/index.d.ts +1 -0
  104. package/dist/security/index.js +2 -0
  105. package/dist/security/rate-rules.d.ts +21 -0
  106. package/dist/security/rate-rules.js +110 -0
  107. package/dist/seo/index.d.ts +3 -0
  108. package/dist/seo/index.js +4 -0
  109. package/dist/seo/resolve.d.ts +68 -0
  110. package/dist/seo/resolve.js +73 -0
  111. package/dist/seo/routes.d.ts +44 -0
  112. package/dist/seo/routes.js +104 -0
  113. package/dist/seo/structured-data.d.ts +51 -0
  114. package/dist/seo/structured-data.js +105 -0
  115. package/dist/status.d.ts +51 -0
  116. package/dist/status.js +113 -0
  117. package/dist/worker/generate.d.ts +18 -10
  118. package/dist/worker/generate.js +353 -42
  119. package/dist/worker/routes.d.ts +1 -1
  120. package/dist/worker/routes.js +42 -0
  121. package/dist/workflow/advance.d.ts +102 -0
  122. package/dist/workflow/advance.js +145 -0
  123. package/dist/workflow/config.d.ts +60 -0
  124. package/dist/workflow/config.js +73 -0
  125. package/dist/workflow/generate.d.ts +22 -0
  126. package/dist/workflow/generate.js +138 -0
  127. package/dist/workflow/index.d.ts +3 -0
  128. package/dist/workflow/index.js +4 -0
  129. package/package.json +21 -4
  130. package/src/components/Editable.astro +33 -9
  131. package/src/components/JustifiedGallery.astro +254 -0
  132. package/src/components/MediaSlot.astro +178 -0
  133. package/src/components/PortalShell.astro +80 -0
  134. package/src/components/RegisterSW.astro +45 -0
  135. package/src/components/Section.astro +101 -35
  136. package/src/components/Sections.astro +64 -0
  137. package/src/components/Seo.astro +57 -0
  138. package/src/components/StageBar.astro +137 -0
  139. package/src/components/StructuredData.astro +33 -0
  140. package/src/components/justify.ts +170 -0
  141. package/src/components/media-meta.ts +174 -0
  142. package/src/components/sections/AboutIntro.astro +46 -0
  143. package/src/components/sections/Banner.astro +31 -0
  144. package/src/components/sections/Contact.astro +22 -9
  145. package/src/components/sections/Cta.astro +33 -10
  146. package/src/components/sections/Faq.astro +50 -0
  147. package/src/components/sections/FeatureGrid.astro +40 -11
  148. package/src/components/sections/Gallery.astro +46 -0
  149. package/src/components/sections/Hero.astro +40 -12
  150. package/src/components/sections/LocationHours.astro +59 -0
  151. package/src/components/sections/Media.astro +44 -0
  152. package/src/components/sections/PricingTiers.astro +79 -0
  153. package/src/components/sections/ProductGrid.astro +73 -0
  154. package/src/components/sections/SplitImage.astro +61 -0
  155. package/src/components/sections/Steps.astro +58 -0
  156. package/src/components/sections/Testimonial.astro +51 -0
  157. package/src/components/sections.ts +452 -67
@@ -0,0 +1,86 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Resolving the portal session once per request, and guarding mutations.
4
+ //
5
+ // The middleware resolves it (to gate routes) and so does whatever handler runs
6
+ // next (to know who's asking). Both hitting the session store is a wasted D1
7
+ // round-trip on every authenticated request, so the in-flight promise is shared
8
+ // per request via a `WeakMap` — keyed on the `Request`, which means entries
9
+ // disappear with the request rather than needing eviction.
10
+ //
11
+ // `requireCustomer` then adds the check a session alone doesn't give you:
12
+ // same-origin on mutations. A cookie is attached by the browser to any request
13
+ // to this origin, including one a third-party page triggered — so a session
14
+ // proves identity, and the origin check proves intent.
15
+ const inFlight = new WeakMap();
16
+ /**
17
+ * Resolve the portal session at most once per request.
18
+ *
19
+ * Shares the *promise*, not the result, so two callers racing during the same
20
+ * request both await one lookup rather than starting a second.
21
+ */
22
+ export function resolvePortalSession(request, resolve) {
23
+ const existing = inFlight.get(request);
24
+ if (existing)
25
+ return existing;
26
+ // A rejected lookup degrades to signed-out rather than propagating: missing
27
+ // bindings under plain `astro preview` shouldn't 500 a public page.
28
+ const promise = resolve(request).catch(() => null);
29
+ inFlight.set(request, promise);
30
+ return promise;
31
+ }
32
+ /** JSON response helper — the shape every portal API route returns. */
33
+ export function json(body, status = 200, headers = {}) {
34
+ return new Response(JSON.stringify(body), {
35
+ status,
36
+ headers: { "content-type": "application/json", ...headers },
37
+ });
38
+ }
39
+ /** Methods that change state, and therefore need the origin check. */
40
+ const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
41
+ /** True when the request came from this same origin. */
42
+ export function isSameOrigin(request) {
43
+ const target = new URL(request.url).origin;
44
+ const origin = request.headers.get("origin");
45
+ if (origin)
46
+ return origin === target;
47
+ // No Origin header: browsers always send one on cross-origin mutations, so
48
+ // its absence means a same-origin or non-browser caller. Fall back to Referer
49
+ // when present, and allow otherwise — being stricter would break legitimate
50
+ // server-to-server callers without stopping a real CSRF, which always carries
51
+ // an Origin.
52
+ const referer = request.headers.get("referer");
53
+ if (referer) {
54
+ try {
55
+ return new URL(referer).origin === target;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ return true;
62
+ }
63
+ /**
64
+ * Guard a portal API handler: a signed-in user, and — on mutations — a
65
+ * same-origin request.
66
+ *
67
+ * ```ts
68
+ * const guard = await requireCustomer(request, (req) => portalUser(req));
69
+ * if (!guard.ok) return guard.response;
70
+ * // guard.user is signed in and this is a same-origin call
71
+ * ```
72
+ *
73
+ * `roles` narrows further, for a route only some portal users may reach.
74
+ */
75
+ export async function requireCustomer(request, resolve, options = {}) {
76
+ if (MUTATING.has(request.method) && !isSameOrigin(request)) {
77
+ return { ok: false, response: json({ ok: false, error: "Forbidden" }, 403) };
78
+ }
79
+ const user = await resolvePortalSession(request, resolve);
80
+ if (!user)
81
+ return { ok: false, response: json({ ok: false, error: "Unauthorized" }, 401) };
82
+ if (options.roles?.length && !options.roles.includes(user.role)) {
83
+ return { ok: false, response: json({ ok: false, error: "Forbidden" }, 403) };
84
+ }
85
+ return { ok: true, user };
86
+ }
@@ -0,0 +1 @@
1
+ export { generateAstroidGalleryPage } from "./scaffold.js";
@@ -0,0 +1,4 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The `portfolio` archetype's scaffold surface.
4
+ export { generateAstroidGalleryPage } from "./scaffold.js";
@@ -0,0 +1,9 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /**
3
+ * `src/pages/work.astro` — the portfolio gallery. Null for any other archetype.
4
+ *
5
+ * Intrinsic `width`/`height` are carried through deliberately: they feed the
6
+ * pre-decode layout, so a library with dimensions recorded lays out correctly on
7
+ * first paint instead of reflowing once the images decode.
8
+ */
9
+ export declare function generateAstroidGalleryPage(config: AstroidConfig): string | null;
@@ -0,0 +1,93 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The `portfolio` archetype's scaffold-once page: a justified gallery over the
4
+ // media library.
5
+ //
6
+ // Scaffold-once, not regenerated, for the usual reason — this is the first file
7
+ // a portfolio site edits (which assets appear, in what order, whether tiles link
8
+ // to a detail page), so `astroid generate` must never rewrite it.
9
+ //
10
+ // It exists because the primitives alone don't finish the job. `<MediaSlot>` and
11
+ // `<JustifiedGallery>` are archetype-agnostic, but the wiring between them and
12
+ // the media registry — the public URL shape, filtering to images, carrying
13
+ // alt/caption and intrinsic dimensions through so the first paint isn't a guess
14
+ // — is identical every time, and is exactly what the consuming sites hand-wrote.
15
+ /**
16
+ * `src/pages/work.astro` — the portfolio gallery. Null for any other archetype.
17
+ *
18
+ * Intrinsic `width`/`height` are carried through deliberately: they feed the
19
+ * pre-decode layout, so a library with dimensions recorded lays out correctly on
20
+ * first paint instead of reflowing once the images decode.
21
+ */
22
+ export function generateAstroidGalleryPage(config) {
23
+ if (config.archetype !== "portfolio")
24
+ return null;
25
+ const mediaBase = config.deploy?.mediaBase ?? "/media";
26
+ return [
27
+ "---",
28
+ "// The work gallery — a justified grid over the media library.",
29
+ "//",
30
+ "// Scaffolded once; yours to edit. The layout primitive is general",
31
+ "// (astroidjs/components/JustifiedGallery.astro); what lives here is this",
32
+ "// project's own answer to *which* assets appear and in what order.",
33
+ "//",
34
+ "// Rows carry `alt`/`caption` from the media registry, so an editor fixes alt",
35
+ "// text once in the library and every gallery showing that asset picks it up.",
36
+ "// Assets missing width/height still render — the client corrects the layout",
37
+ "// once the image decodes — but they cost a visible reflow, so it's worth",
38
+ "// backfilling dimensions on older uploads.",
39
+ 'import JustifiedGallery from "astroidjs/components/JustifiedGallery.astro";',
40
+ 'import type { GalleryItem } from "astroidjs/components/justify";',
41
+ 'import { env } from "cloudflare:workers";',
42
+ 'import Site from "../layouts/Site.astro";',
43
+ "",
44
+ "export const prerender = false;",
45
+ "",
46
+ "interface MediaRow {",
47
+ " key: string;",
48
+ " alt: string | null;",
49
+ " caption: string | null;",
50
+ " width: number | null;",
51
+ " height: number | null;",
52
+ "}",
53
+ "",
54
+ "let rows: MediaRow[] = [];",
55
+ "try {",
56
+ " const result = await env.DB.prepare(",
57
+ ' "SELECT key, alt, caption, width, height FROM media" +',
58
+ " \" WHERE content_type LIKE 'image/%' ORDER BY uploaded_at DESC LIMIT 120\",",
59
+ " ).all<MediaRow>();",
60
+ " rows = result.results ?? [];",
61
+ "} catch {",
62
+ " // No DB binding yet (pre-provision) — render the empty state.",
63
+ "}",
64
+ "",
65
+ "const items: GalleryItem[] = rows.map((row) => ({",
66
+ ` src: \`${mediaBase}/\${row.key}\`,`,
67
+ ' // An asset with no alt gets "" rather than its filename: an empty alt makes',
68
+ " // a screen reader skip a decorative tile, while a filename is read aloud",
69
+ " // character by character and tells the listener nothing.",
70
+ ' alt: row.alt ?? "",',
71
+ " ...(row.caption ? { caption: row.caption } : {}),",
72
+ " ...(row.width ? { width: row.width } : {}),",
73
+ " ...(row.height ? { height: row.height } : {}),",
74
+ "}));",
75
+ "---",
76
+ "",
77
+ '<Site title="Work">',
78
+ ' <main class="mx-auto max-w-6xl px-6 py-16">',
79
+ ' <h1 class="text-4xl font-bold">Work</h1>',
80
+ " {",
81
+ " items.length > 0 ? (",
82
+ ' <JustifiedGallery items={items} class="mt-8" />',
83
+ " ) : (",
84
+ ' <p class="mt-6 opacity-70">',
85
+ " Upload images in the media library and they&apos;ll appear here.",
86
+ " </p>",
87
+ " )",
88
+ " }",
89
+ " </main>",
90
+ "</Site>",
91
+ "",
92
+ ].join("\n");
93
+ }
@@ -0,0 +1,3 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** `src/actions/index.ts` — the typed mutation surface, scaffolded once. */
3
+ export declare function generateAstroidActions(config: AstroidConfig): string;
@@ -0,0 +1,121 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // `src/actions/index.ts` — the Astro-native, typed mutation surface (ADR 0001
4
+ // layer 2), beside the framework-agnostic `/api/louise/*` routes.
5
+ //
6
+ // Astroid generated only the route half. That is not a missing convenience: the
7
+ // two entrypoints write the SAME rows, and the whole reason `louise-toolkit/astro`
8
+ // exposes these factories is that each one shares the raw route's store path —
9
+ // `applyFieldSave`, `applySettingsPatch`, `applySaveDraft`. A project that wired
10
+ // its own Actions by hand would get a second write path, and a second write path
11
+ // is where validation, sanitization, and draft-merge semantics drift apart
12
+ // silently (#138).
13
+ //
14
+ // So this file is SCAFFOLD-ONCE and is meant to be added to — the reference site
15
+ // keeps its own bespoke actions right beside these — but the three below come
16
+ // pre-wired against the same tables and the same collection config the generated
17
+ // worker uses.
18
+ /** `src/actions/index.ts` — the typed mutation surface, scaffolded once. */
19
+ export function generateAstroidActions(config) {
20
+ const customKeys = config.settings?.customKeys ?? [];
21
+ const extraImageKeys = config.settings?.imageKeys ?? [];
22
+ const columnsOverride = config.settings?.columns;
23
+ // Kept in step with the generated worker's settingsRoute: site-specific keys go
24
+ // to site_settings.custom, extra image keys widen the media-strict set, a
25
+ // custom-heavy site can override the base columns. Emitted as literals only
26
+ // when present, so a stock project's Action is unchanged.
27
+ const settingsExtra = [
28
+ columnsOverride
29
+ ? ` columns: ${JSON.stringify(columnsOverride)},`
30
+ : " columns: ASTROID_SETTINGS_COLUMNS,",
31
+ ...(customKeys.length ? [` customKeys: ${JSON.stringify(customKeys)},`] : []),
32
+ extraImageKeys.length
33
+ ? ` imageKeys: [...ASTROID_SETTINGS_IMAGE_KEYS, ...${JSON.stringify(extraImageKeys)}],`
34
+ : " imageKeys: ASTROID_SETTINGS_IMAGE_KEYS,",
35
+ ];
36
+ return [
37
+ "// The typed Astro Actions surface — ADR 0001 layer 2.",
38
+ "//",
39
+ "// Scaffolded once and yours to ADD to: put your own `defineAction`s in the",
40
+ "// `server` object below, alongside these.",
41
+ "//",
42
+ "// What matters about the three that ship here is that they are NOT a second",
43
+ "// implementation. Each factory shares the store path of its raw",
44
+ "// `/api/louise/*` counterpart (`applyFieldSave`, `applySettingsPatch`,",
45
+ "// `applySaveDraft`), so a field is validated once and written in exactly one",
46
+ "// place however it was called. Hand-rolling an Action that writes the same",
47
+ "// row is how sanitization and draft-merge semantics drift apart without",
48
+ "// anything failing.",
49
+ 'import { ActionError, defineAction } from "astro:actions";',
50
+ 'import { env } from "cloudflare:workers";',
51
+ "import {",
52
+ " louiseSaveAction,",
53
+ " louiseSaveDraftAction,",
54
+ " louiseSettingsAction,",
55
+ '} from "louise-toolkit/astro";',
56
+ "import {",
57
+ " ASTROID_SETTINGS_COLUMNS,",
58
+ " ASTROID_SETTINGS_IMAGE_KEYS,",
59
+ " astroidPagesCollection,",
60
+ '} from "astroidjs";',
61
+ 'import astroidConfig from "../../astroid.config.js";',
62
+ 'import { pages, pagesVersions, siteSettings } from "../schema.js";',
63
+ "",
64
+ "const pagesCollection = astroidPagesCollection(astroidConfig);",
65
+ "",
66
+ "// Astro v6+ removed `Astro.locals.runtime.env`, so the bindings are resolved",
67
+ "// from `cloudflare:workers` — the same env the raw routes read.",
68
+ "const getEnv = () => env as unknown as CloudflareEnv;",
69
+ "",
70
+ "// `getEditor` is left to its default (`locals.editor`), which the generated",
71
+ "// middleware sets for a signed-in editor. A falsy result answers 401, so these",
72
+ "// carry the same gate as the routes rather than a parallel one.",
73
+ "const deps = { ActionError, getEnv };",
74
+ "",
75
+ "export const server = {",
76
+ " louise: {",
77
+ " // Inline field save (title, SEO) — the live, non-versioned path.",
78
+ " save: defineAction(",
79
+ " louiseSaveAction({",
80
+ " ...deps,",
81
+ " collections: {",
82
+ " pages: {",
83
+ " table: pages,",
84
+ ' fields: ["title", "seoTitle", "seoDescription"],',
85
+ " },",
86
+ " },",
87
+ " }),",
88
+ " ),",
89
+ "",
90
+ " // The versioned body/sections save — stages a DRAFT, exactly as",
91
+ " // versionsRoute does, through the same `applySaveDraft`.",
92
+ " saveDraft: defineAction(",
93
+ " louiseSaveDraftAction({",
94
+ " ...deps,",
95
+ " table: pages,",
96
+ " versionsTable: pagesVersions,",
97
+ " config: pagesCollection,",
98
+ " // The same KV write-buffer the route uses. Both entrypoints coalesce",
99
+ " // through one buffer, so an autosave burst is one D1 write however",
100
+ " // the client happened to call in.",
101
+ " bufferKv: (e) => e.DRAFTS,",
102
+ " }),",
103
+ " ),",
104
+ "",
105
+ " // The Settings-panel patch (brand, nav, contact, SEO defaults).",
106
+ " settings: defineAction(",
107
+ " louiseSettingsAction({",
108
+ " ...deps,",
109
+ " table: siteSettings,",
110
+ " // The SAME allowlist the generated worker enforces, imported rather",
111
+ " // than copied — a second literal here is a list that drifts from the",
112
+ " // one the routes check against, and nothing would fail when it did.",
113
+ ...settingsExtra,
114
+ ' mediaBase: astroidConfig.deploy?.mediaBase ?? "/media",',
115
+ " }),",
116
+ " ),",
117
+ " },",
118
+ "};",
119
+ "",
120
+ ].join("\n");
121
+ }
@@ -12,6 +12,21 @@ export interface GeneratedFile {
12
12
  * once files (wrangler.jsonc, astro.config, auth.ts) are NOT here by design.
13
13
  */
14
14
  export declare function generateAstroidProject(config: AstroidConfig): GeneratedFile[];
15
+ /**
16
+ * The module-secret block `create-astroid` substitutes into `.env.example`.
17
+ *
18
+ * Every name is seeded with the placeholder sentinel rather than left empty,
19
+ * which is the whole trick behind a scaffold that runs with no accounts: the
20
+ * bindings all EXIST and all read as unconfigured, so each module takes its
21
+ * dormant path deliberately instead of hitting an undefined-binding error. The
22
+ * names come from {@link commerceSecretNames}, the same declaration the runtime
23
+ * gate and the generated `env.d.ts` read.
24
+ *
25
+ * Empty string when the project enables no module that needs credentials — the
26
+ * core secrets (session, Turnstile, mail) are already in the template file, with
27
+ * their own prose.
28
+ */
29
+ export declare function generateAstroidSecretsEnv(config: AstroidConfig): string;
15
30
  /**
16
31
  * Generate a floor `wrangler.jsonc` from the config: the Worker name + editable
17
32
  * bindings a baseline Louise site needs (D1, R2 media, the rate-limit + autosave
@@ -14,6 +14,13 @@
14
14
  // them (fills real binding ids, secrets, account). `astroid generate` must
15
15
  // NEVER clobber them, or it would wipe provisioned ids — so they live in a
16
16
  // separate function the regenerate path doesn't call.
17
+ import { ASTROID_VITALS_BINDING, astroidVitalsDataset, } from "../analytics/index.js";
18
+ import { astroidCheckoutVars } from "../commerce/checkout-scaffold.js";
19
+ import { astroidCommerceProviders } from "../commerce/roles.js";
20
+ import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceSecretNames, } from "../commerce/secrets.js";
21
+ import { ASTROID_QUEUE_BINDING, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
22
+ import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, usesRealtime, } from "../realtime/scaffold.js";
23
+ import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
17
24
  import { generateAstroidSchema } from "../schema/generate.js";
18
25
  import { generateAstroidMiddleware, generateAstroidWorker } from "../worker/generate.js";
19
26
  /**
@@ -29,6 +36,43 @@ export function generateAstroidProject(config) {
29
36
  { path: "src/middleware.ts", contents: generateAstroidMiddleware(config) },
30
37
  ];
31
38
  }
39
+ /**
40
+ * The module-secret block `create-astroid` substitutes into `.env.example`.
41
+ *
42
+ * Every name is seeded with the placeholder sentinel rather than left empty,
43
+ * which is the whole trick behind a scaffold that runs with no accounts: the
44
+ * bindings all EXIST and all read as unconfigured, so each module takes its
45
+ * dormant path deliberately instead of hitting an undefined-binding error. The
46
+ * names come from {@link commerceSecretNames}, the same declaration the runtime
47
+ * gate and the generated `env.d.ts` read.
48
+ *
49
+ * Empty string when the project enables no module that needs credentials — the
50
+ * core secrets (session, Turnstile, mail) are already in the template file, with
51
+ * their own prose.
52
+ */
53
+ export function generateAstroidSecretsEnv(config) {
54
+ const providers = astroidCommerceProviders(config.commerce);
55
+ if (providers.length === 0)
56
+ return "";
57
+ const lines = [
58
+ "",
59
+ "# --- commerce -------------------------------------------------------------",
60
+ "#",
61
+ "# Seeded with the DUMMY_REPLACE_ME sentinel, which reads as NOT CONFIGURED.",
62
+ "# Commerce is dormant until every value below is real: the D1 catalog mirror",
63
+ "# still serves whatever it last synced, the webhook receiver answers 503 (so",
64
+ "# the provider retries rather than dropping events), and checkout is",
65
+ "# simulated. Nothing calls the provider with a placeholder credential.",
66
+ ];
67
+ for (const provider of providers) {
68
+ const spec = COMMERCE_PROVIDER_SECRETS[provider];
69
+ lines.push("#", `# ${provider}: ${COMMERCE_PROVIDER_SETUP[provider]}`);
70
+ for (const name of [...spec.credentials, spec.webhook]) {
71
+ lines.push(`${name}=${ASTROID_SECRET_PLACEHOLDER}`);
72
+ }
73
+ }
74
+ return lines.join("\n");
75
+ }
32
76
  // Pinned compatibility date for the emitted Worker. A literal (Astroid's
33
77
  // generators are pure — no `Date.now()`), bumped deliberately when the runtime
34
78
  // baseline moves; matches the reference site's wrangler.jsonc.
@@ -73,6 +117,50 @@ export function generateAstroidWrangler(config) {
73
117
  p(" // No `hosts` in your config → deploys to <name>.workers.dev. Add a");
74
118
  p(' // "routes" block with a custom_domain pattern to serve a real domain.');
75
119
  }
120
+ if (usesRealtime(config)) {
121
+ // The per-page live editing session (ADR 0002). Two halves, and BOTH are
122
+ // required — a binding with no migration is a deploy error, and the class
123
+ // must also be exported from the worker entry (the generated src/worker.ts
124
+ // re-exports it) or wrangler can't resolve `class_name`.
125
+ p(" // Durable Object: the per-page live editing session (realtime module).");
126
+ p(' "durable_objects": {');
127
+ p(` "bindings": [{ "name": ${JSON.stringify(ASTROID_REALTIME_BINDING)}, "class_name": ${JSON.stringify(ASTROID_EDIT_SESSION_CLASS)} }]`);
128
+ p(" },");
129
+ p(" // A DO class needs a migration tag. `new_sqlite_classes` (NOT");
130
+ p(" // `new_classes`) because the session keeps its authoritative state in");
131
+ p(" // `ctx.storage`, which is the SQLite-backed store — and the storage");
132
+ p(" // backend cannot be changed after the class is first deployed.");
133
+ p(" \"migrations\": [");
134
+ p(` { "tag": ${JSON.stringify(ASTROID_REALTIME_MIGRATION_TAG)}, "new_sqlite_classes": [${JSON.stringify(ASTROID_EDIT_SESSION_CLASS)}] }`);
135
+ p(" ],");
136
+ }
137
+ // Crons. ONE `scheduled` handler receives all of them and tells them apart by
138
+ // `controller.cron`, so this list and the handler's dispatch must agree
139
+ // exactly — both come from `astroidCrons`, which is why it exists.
140
+ //
141
+ // Daily: the site-health scan (broken links, missing alt text, SEO gaps).
142
+ // Hourly (commerce only): the catalog re-sync safety net, so a missed or DLQ'd
143
+ // webhook can only leave the site stale until the next tick.
144
+ p(` "triggers": { "crons": ${JSON.stringify(astroidCrons(config))} },`);
145
+ if (astroidUsesQueues(config)) {
146
+ const { queue, dlq } = astroidQueueNames(config);
147
+ p(" // Provider webhooks are verified at the edge, then enqueued here so the");
148
+ p(" // receiver can return fast. Retries + DLQ routing are Cloudflare's, not");
149
+ p(" // the consumer's — set them here, not in code.");
150
+ p(` // Create both: \`wrangler queues create ${queue}\` and \`… ${dlq}\`.`);
151
+ p(' "queues": {');
152
+ p(` "producers": [{ "queue": ${JSON.stringify(queue)}, "binding": ${JSON.stringify(ASTROID_QUEUE_BINDING)} }],`);
153
+ p(' "consumers": [');
154
+ p(" {");
155
+ p(` "queue": ${JSON.stringify(queue)},`);
156
+ p(` "max_batch_size": ${config.queues?.maxBatchSize ?? 10},`);
157
+ p(` "max_batch_timeout": ${config.queues?.maxBatchTimeout ?? 30},`);
158
+ p(` "max_retries": ${config.queues?.maxRetries ?? 5},`);
159
+ p(` "dead_letter_queue": ${JSON.stringify(dlq)},`);
160
+ p(" },");
161
+ p(" ],");
162
+ p(" },");
163
+ }
76
164
  p(" // D1 holds pages / site_settings / media / inquiries (schema in src/schema.ts,");
77
165
  p(" // migrations in ./migrations). Create it: `wrangler d1 create <name>`.");
78
166
  p(' "d1_databases": [');
@@ -89,20 +177,74 @@ export function generateAstroidWrangler(config) {
89
177
  p(" // Cloudflare Images: the media route reads upload dimensions + backs server-");
90
178
  p(" // side re-encode. Also @astrojs/cloudflare's production image service.");
91
179
  p(' "images": { "binding": "IMAGES" },');
92
- p(" // KV: RL = the security rate limiter; DRAFTS = the autosave write-buffer.");
180
+ p(" // Analytics Engine: real-visitor Core Web Vitals. Free, and the ingest");
181
+ p(" // route accepts-and-drops without it, so it costs nothing unused. Reading");
182
+ p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example) —");
183
+ p(" // until those are real the Health badge reads 'not measured yet'.");
184
+ p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_VITALS_BINDING)}, "dataset": ${JSON.stringify(astroidVitalsDataset(config))} }],`);
185
+ p(" // Workers AI. Powers the editor's rewrite + SEO-suggest buttons and alt-text");
186
+ p(" // generation on upload — all of which SHIP IN THE EDITOR DRAWER already and,");
187
+ p(" // without this binding, were permanently invisible: their routes answer 503");
188
+ p(" // and the client hides the button. No account setup beyond the binding, and");
189
+ p(" // every call is editor-gated, so a visitor can never spend your AI budget.");
190
+ p(' "ai": { "binding": "AI" },');
191
+ p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
192
+ p(" // summary under its own key — one small singleton blob, not worth a binding");
193
+ p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
93
194
  p(" // Create each: `wrangler kv namespace create <RL|DRAFTS>`.");
94
195
  p(' "kv_namespaces": [');
95
196
  p(' { "binding": "RL", "id": "<run: wrangler kv namespace create RL>" },');
96
197
  p(' { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create DRAFTS>" },');
97
198
  p(" ],");
199
+ // Email Sending. NOT optional decoration: `src/env.d.ts` declares EMAIL as a
200
+ // required member, and Better Auth's magic-link path console-logs the link in
201
+ // dev but calls `env.EMAIL.send(...)` unconditionally in production. Without
202
+ // this binding that call is a TypeError on a binding that was never created —
203
+ // so sign-in was impossible on every DEPLOYED site, while every local build
204
+ // and every CI scaffold passed. Nothing in this repo runs a deployed scaffold,
205
+ // which is why it survived.
206
+ p(" // Cloudflare Email Sending — magic-link sign-in + inquiry notifications.");
207
+ p(" // Sign-in DEPENDS on this: in production the magic link is emailed, not logged.");
208
+ p(" // Enable Email Sending for your zone, then verify the address in MAIL_FROM.");
209
+ p(' "send_email": [{ "name": "EMAIL" }],');
98
210
  p(" // Public base for media URLs; same-origin keeps media self-contained. Read off");
99
211
  p(" // the runtime env by the framework-agnostic media route, so it stays a `var`.");
100
212
  p(' "vars": {');
101
213
  p(` "MEDIA_URL": ${JSON.stringify(mediaBase)},`);
102
214
  p(` "SITE_URL": ${JSON.stringify(primaryHost ? `https://${primaryHost}` : `https://${key}.workers.dev`)},`);
103
- p(' // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).');
215
+ p(" // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).");
104
216
  p(' "OWNER_EMAIL": "",');
217
+ p(" // Edge caching for published pages (ADR 0004). OFF by default, and the");
218
+ p(" // default is the safe state: with it off every render is `no-store` and");
219
+ p(" // the Worker cache layer stores nothing.");
220
+ p(" //");
221
+ p(" // Turn it on for a PREVIEW deploy first and walk the activation runbook");
222
+ p(" // (docs/adr/0004-edge-caching.md). `caches.default` is not cleared by");
223
+ p(" // Cloudflare Dev Mode or Purge Everything, so a bad prod flip is hard to");
224
+ p(" // undo — this feature was reverted twice for exactly that.");
225
+ p(' "ASTROID_EDGE_CACHE": "false",');
226
+ for (const v of astroidCheckoutVars(config)) {
227
+ // Public, not secret — the app id ships to the browser to mount the card
228
+ // field, and the environment is a choice. Keeping them out of the secret
229
+ // roster also keeps them out of the dormancy gate, which asks whether we can
230
+ // safely CALL Square, not whether a card field can render.
231
+ p(` ${JSON.stringify(v.name)}: ${JSON.stringify(v.value)},`);
232
+ }
105
233
  p(" },");
234
+ // Secrets are NOT vars: they belong in .dev.vars locally and in `wrangler
235
+ // secret put` / Secrets Store when deployed. Listing the names here is
236
+ // deliberate — this is the file someone opens when provisioning, and the list
237
+ // is generated from the same declaration the runtime dormancy gate reads.
238
+ const secretNames = commerceSecretNames(config.commerce);
239
+ if (secretNames.length > 0) {
240
+ p(" // Commerce secrets — set OUTSIDE this file (it's committed):");
241
+ p(" // local: .dev.vars (see .env.example, seeded with DUMMY_REPLACE_ME)");
242
+ p(" // deployed: `wrangler secret put <NAME>`, or a Secrets Store binding");
243
+ p(" // Until each is real, commerce stays dormant: the D1 mirror serves, the");
244
+ p(" // webhook receiver answers 503, and nothing calls the provider.");
245
+ for (const name of secretNames)
246
+ p(` // ${name}`);
247
+ }
106
248
  p(' "observability": { "enabled": true },');
107
249
  p("}");
108
250
  p();
@@ -1 +1,3 @@
1
1
  export * from "./generate.js";
2
+ export * from "./actions.js";
3
+ export * from "./scaffold.js";
@@ -3,3 +3,5 @@
3
3
  // Project generation — config → the files the `astroid` CLI writes (the
4
4
  // regenerated schema/worker/middleware trio + the scaffold-once wrangler.jsonc).
5
5
  export * from "./generate.js";
6
+ export * from "./actions.js";
7
+ export * from "./scaffold.js";
@@ -0,0 +1,29 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /**
3
+ * A file written once, then owned by the project.
4
+ *
5
+ * `apply` is the whole contract. `"skip"` (the default) leaves an existing file
6
+ * alone. `"append-once"` is for the files a project accumulates into rather than
7
+ * owns outright — `public/_headers` gets a stanza per module, and a second
8
+ * module must not erase the first one's.
9
+ */
10
+ export interface ScaffoldFile {
11
+ /** Path relative to the project root, POSIX-separated. */
12
+ path: string;
13
+ contents: string;
14
+ /** What to do when the path already exists. Default `"skip"`. */
15
+ apply?: "skip" | "append-once";
16
+ /**
17
+ * For `"append-once"`: a substring that proves this stanza is already there.
18
+ * Without it a re-run would append a duplicate every time.
19
+ */
20
+ marker?: string;
21
+ }
22
+ /**
23
+ * Every scaffold-once file this config implies.
24
+ *
25
+ * Ordered by module so a `generate` that writes several prints them in a stable
26
+ * sequence. Returns `[]` for a plain marketing site with no modules — the
27
+ * baseline floor is entirely the regenerated trio plus the static template.
28
+ */
29
+ export declare function generateAstroidScaffoldFiles(config: AstroidConfig): ScaffoldFile[];