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.
- 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/auth/index.d.ts +27 -0
- package/dist/auth/index.js +59 -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 +69 -0
- package/dist/commerce/mirror.js +214 -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 +239 -12
- package/dist/config.js +49 -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 +15 -0
- package/dist/index.js +15 -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 +56 -0
- package/dist/portal/guard.d.ts +54 -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 +121 -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 +166 -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 +110 -12
- package/dist/schema/framework.js +5 -0
- package/dist/schema/generate.js +17 -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 +110 -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 +353 -42
- 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
|
@@ -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,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'll appear here.",
|
|
86
|
+
" </p>",
|
|
87
|
+
" )",
|
|
88
|
+
" }",
|
|
89
|
+
" </main>",
|
|
90
|
+
"</Site>",
|
|
91
|
+
"",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
@@ -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
|
package/dist/project/generate.js
CHANGED
|
@@ -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(" //
|
|
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(
|
|
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();
|
package/dist/project/index.d.ts
CHANGED
package/dist/project/index.js
CHANGED
|
@@ -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[];
|