astroidjs 0.13.0 → 0.14.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/bin/astroid.mjs +9 -3
- package/dist/analytics/index.js +2 -2
- package/dist/commerce/checkout-scaffold.js +18 -18
- package/dist/commerce/mirror.js +3 -3
- package/dist/config.d.ts +27 -0
- package/dist/map/scaffold.js +4 -4
- package/dist/portal/config.d.ts +1 -1
- package/dist/portal/config.js +9 -8
- package/dist/portal/scaffold.js +7 -7
- package/dist/portal/session.d.ts +9 -3
- package/dist/portal/session.js +5 -0
- package/dist/portfolio/scaffold.js +4 -4
- package/dist/project/actions.js +12 -6
- package/dist/project/generate.js +11 -11
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +1 -0
- package/dist/project/scaffold.js +58 -2
- package/dist/project/seed.d.ts +22 -0
- package/dist/project/seed.js +214 -0
- package/dist/pwa/generate.js +7 -7
- package/dist/queues/scaffold.js +9 -9
- package/dist/realtime/scaffold.js +4 -4
- package/dist/schema/collections.d.ts +36 -5
- package/dist/schema/collections.js +21 -20
- package/dist/schema/generate.js +4 -4
- package/dist/tenancy/index.js +5 -5
- package/dist/worker/generate.js +72 -38
- package/dist/worker/routes.js +10 -10
- package/dist/workflow/generate.js +7 -7
- package/package.json +3 -3
package/dist/project/generate.js
CHANGED
|
@@ -98,7 +98,7 @@ export function generateAstroidWrangler(config) {
|
|
|
98
98
|
const p = (s = "") => lines.push(s);
|
|
99
99
|
p("{");
|
|
100
100
|
p(' "$schema": "node_modules/wrangler/config-schema.json",');
|
|
101
|
-
p(" // The Cloudflare Worker name
|
|
101
|
+
p(" // The Cloudflare Worker name—also the default *.workers.dev subdomain.");
|
|
102
102
|
p(` "name": ${JSON.stringify(key)},`);
|
|
103
103
|
p(" // Pin your account so deploys don't prompt (or set CLOUDFLARE_ACCOUNT_ID).");
|
|
104
104
|
p(' // "account_id": "<your-cloudflare-account-id>",');
|
|
@@ -108,7 +108,7 @@ export function generateAstroidWrangler(config) {
|
|
|
108
108
|
p(" // global_fetch_strictly_public: a fetch to this zone goes through Cloudflare's");
|
|
109
109
|
p(" // front door like any Internet request, past the WAF and bot rules (ADR 0012).");
|
|
110
110
|
p(" // If the daily health scan starts reporting broken links that aren't, a zone");
|
|
111
|
-
p(" // rule is challenging its self-crawl
|
|
111
|
+
p(" // rule is challenging its self-crawl—allow it rather than drop the flag.");
|
|
112
112
|
p(' "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],');
|
|
113
113
|
p(" // @astrojs/cloudflare builds this entry and wires the static assets under dist/.");
|
|
114
114
|
p(' "main": "src/worker.ts",');
|
|
@@ -124,7 +124,7 @@ export function generateAstroidWrangler(config) {
|
|
|
124
124
|
// `custom_domain: true` on a pattern containing `*`, which is precisely
|
|
125
125
|
// why `hosts` cannot express this.
|
|
126
126
|
p(" // Wildcard tenant hosts (`tenancy.hostPattern`). A zone route, NOT a");
|
|
127
|
-
p(" // custom_domain
|
|
127
|
+
p(" // custom_domain—Cloudflare refuses a wildcard custom domain. The");
|
|
128
128
|
p(" // apex is routed separately above: `*.example.com` does NOT match");
|
|
129
129
|
p(" // `example.com`, so without a `hosts` entry the apex would 404.");
|
|
130
130
|
p(` { "pattern": ${JSON.stringify(`${tenancy.hostPattern}/*`)}, "zone_name": ${JSON.stringify(tenancyZone(tenancy))} },`);
|
|
@@ -146,7 +146,7 @@ export function generateAstroidWrangler(config) {
|
|
|
146
146
|
p(" },");
|
|
147
147
|
p(" // A DO class needs a migration tag. `new_sqlite_classes` (NOT");
|
|
148
148
|
p(" // `new_classes`) because the session keeps its authoritative state in");
|
|
149
|
-
p(" // `ctx.storage`, which is the SQLite-backed store
|
|
149
|
+
p(" // `ctx.storage`, which is the SQLite-backed store—and the storage");
|
|
150
150
|
p(" // backend cannot be changed after the class is first deployed.");
|
|
151
151
|
p(' "migrations": [');
|
|
152
152
|
p(` { "tag": ${JSON.stringify(ASTROID_REALTIME_MIGRATION_TAG)}, "new_sqlite_classes": [${JSON.stringify(ASTROID_EDIT_SESSION_CLASS)}] }`);
|
|
@@ -164,7 +164,7 @@ export function generateAstroidWrangler(config) {
|
|
|
164
164
|
const { queue, dlq } = astroidQueueNames(config);
|
|
165
165
|
p(" // Provider webhooks are verified at the edge, then enqueued here so the");
|
|
166
166
|
p(" // receiver can return fast. Retries + DLQ routing are Cloudflare's, not");
|
|
167
|
-
p(" // the consumer's
|
|
167
|
+
p(" // the consumer's—set them here, not in code.");
|
|
168
168
|
p(` // Create both: \`wrangler queues create ${queue}\` and \`… ${dlq}\`.`);
|
|
169
169
|
p(' "queues": {');
|
|
170
170
|
p(` "producers": [{ "queue": ${JSON.stringify(queue)}, "binding": ${JSON.stringify(ASTROID_QUEUE_BINDING)} }],`);
|
|
@@ -197,17 +197,17 @@ export function generateAstroidWrangler(config) {
|
|
|
197
197
|
p(' "images": { "binding": "IMAGES" },');
|
|
198
198
|
p(" // Analytics Engine: real-visitor Core Web Vitals. Free, and the ingest");
|
|
199
199
|
p(" // route accepts-and-drops without it, so it costs nothing unused. Reading");
|
|
200
|
-
p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example)
|
|
200
|
+
p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example)—");
|
|
201
201
|
p(" // until those are real the Health badge reads 'not measured yet'.");
|
|
202
202
|
p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_VITALS_BINDING)}, "dataset": ${JSON.stringify(astroidVitalsDataset(config))} }],`);
|
|
203
203
|
p(" // Workers AI. Powers the editor's rewrite + SEO-suggest buttons and alt-text");
|
|
204
|
-
p(" // generation on upload
|
|
204
|
+
p(" // generation on upload—all of which SHIP IN THE EDITOR DRAWER already and,");
|
|
205
205
|
p(" // without this binding, were permanently invisible: their routes answer 503");
|
|
206
206
|
p(" // and the client hides the button. No account setup beyond the binding, and");
|
|
207
207
|
p(" // every call is editor-gated, so a visitor can never spend your AI budget.");
|
|
208
208
|
p(' "ai": { "binding": "AI" },');
|
|
209
209
|
p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
|
|
210
|
-
p(" // summary under its own key
|
|
210
|
+
p(" // summary under its own key—one small singleton blob, not worth a binding");
|
|
211
211
|
p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
|
|
212
212
|
p(" // Create each: `wrangler kv namespace create <RL|DRAFTS>`.");
|
|
213
213
|
p(' "kv_namespaces": [');
|
|
@@ -221,7 +221,7 @@ export function generateAstroidWrangler(config) {
|
|
|
221
221
|
// sign-in was impossible on every DEPLOYED site, while every local build
|
|
222
222
|
// and every CI scaffold passed. Nothing in this repo runs a deployed scaffold,
|
|
223
223
|
// which is why it survived.
|
|
224
|
-
p(" // Cloudflare Email Sending
|
|
224
|
+
p(" // Cloudflare Email Sending—magic-link sign-in + inquiry notifications.");
|
|
225
225
|
p(" // Sign-in DEPENDS on this: in production the magic link is emailed, not logged.");
|
|
226
226
|
p(" // Enable Email Sending for your zone, then verify the address in MAIL_FROM.");
|
|
227
227
|
p(' "send_email": [{ "name": "EMAIL" }],');
|
|
@@ -239,7 +239,7 @@ export function generateAstroidWrangler(config) {
|
|
|
239
239
|
p(" // Turn it on for a PREVIEW deploy first and walk the activation runbook");
|
|
240
240
|
p(" // (docs/adr/0004-edge-caching.md). `caches.default` is not cleared by");
|
|
241
241
|
p(" // Cloudflare Dev Mode or Purge Everything, so a bad prod flip is hard to");
|
|
242
|
-
p(" // undo
|
|
242
|
+
p(" // undo—this feature was reverted twice for exactly that.");
|
|
243
243
|
p(' "ASTROID_EDGE_CACHE": "false",');
|
|
244
244
|
for (const v of astroidCheckoutVars(config)) {
|
|
245
245
|
// Public, not secret—the app id ships to the browser to mount the card
|
|
@@ -255,7 +255,7 @@ export function generateAstroidWrangler(config) {
|
|
|
255
255
|
// is generated from the same declaration the runtime dormancy gate reads.
|
|
256
256
|
const secretNames = commerceSecretNames(config.commerce);
|
|
257
257
|
if (secretNames.length > 0) {
|
|
258
|
-
p(" // Commerce secrets
|
|
258
|
+
p(" // Commerce secrets—set OUTSIDE this file (it's committed):");
|
|
259
259
|
p(" // local: .dev.vars (see .env.example, seeded with DUMMY_REPLACE_ME)");
|
|
260
260
|
p(" // deployed: `wrangler secret put <NAME>`, or a Secrets Store binding");
|
|
261
261
|
p(" // Until each is real, commerce stays dormant: the D1 mirror serves, the");
|
package/dist/project/index.d.ts
CHANGED
package/dist/project/index.js
CHANGED
package/dist/project/scaffold.js
CHANGED
|
@@ -37,6 +37,51 @@ import { generateAstroidEditSession } from "../realtime/scaffold.js";
|
|
|
37
37
|
import { generatePwaHeaders, generateServiceWorker, generateWebManifest, resolvePwa, } from "../pwa/generate.js";
|
|
38
38
|
import { astroidUsesQueues } from "../queues/messages.js";
|
|
39
39
|
import { generateAstroidQueueSeam, generateAstroidWebhookRoutes } from "../queues/scaffold.js";
|
|
40
|
+
/** `src/pages-hooks.ts`—the site's pages-route hooks, scaffolded once. */
|
|
41
|
+
function generateAstroidPagesHooks() {
|
|
42
|
+
return [
|
|
43
|
+
"// The pages route's site-owned hooks. The generated worker passes",
|
|
44
|
+
"// `pagesHooks` to astroidPagesWriteHooks, which runs your `transform` before",
|
|
45
|
+
"// Astroid's own section sanitize and validate. Scaffolded once and yours to",
|
|
46
|
+
"// edit.",
|
|
47
|
+
"//",
|
|
48
|
+
"// `transform` gets the allowlisted fields of a write and returns what to",
|
|
49
|
+
"// store: normalize the slug, clamp a title, fill a new page's defaults.",
|
|
50
|
+
"// `validate` rejects a write by throwing a LouiseValidationError (a 422).",
|
|
51
|
+
"// `reservedSlugs` adds to the paths no page may take (ASTROID_RESERVED_SLUGS),",
|
|
52
|
+
"// such as one of your own file routes.",
|
|
53
|
+
'import type { AstroidPagesHooks } from "astroidjs";',
|
|
54
|
+
"",
|
|
55
|
+
"export const pagesHooks: AstroidPagesHooks = {",
|
|
56
|
+
" reservedSlugs: [],",
|
|
57
|
+
"};",
|
|
58
|
+
"",
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
/** `src/settings-hooks.ts`—the site's settings sanitizers, scaffolded once. */
|
|
62
|
+
function generateAstroidSettingsHooks() {
|
|
63
|
+
return [
|
|
64
|
+
"// The Settings panel's sanitize + read hooks. The generated worker spreads",
|
|
65
|
+
"// `settingsHooks` into its settingsRoute call, and src/actions/index.ts",
|
|
66
|
+
"// passes `sanitize` to the settings Action, so both write paths clean a value",
|
|
67
|
+
"// the same way. Scaffolded once and yours to edit.",
|
|
68
|
+
"//",
|
|
69
|
+
"// `sanitize` maps a settings key to a function that returns the value to",
|
|
70
|
+
"// store. It runs before the link-scheme and media-URL checks, and only on",
|
|
71
|
+
"// keys the allowlist (settings.columns + settings.customKeys) already accepts.",
|
|
72
|
+
"// `read` transforms the merged settings on GET, for example to fill keys an",
|
|
73
|
+
"// older row lacks from your defaults.",
|
|
74
|
+
'import type { SettingsRouteHooks } from "louise-toolkit/editor";',
|
|
75
|
+
"",
|
|
76
|
+
"export const settingsHooks: SettingsRouteHooks = {",
|
|
77
|
+
" sanitize: {",
|
|
78
|
+
" // For example, trim and cap a headline:",
|
|
79
|
+
' // heroHeadline: (v) => (typeof v === "string" ? v.trim().slice(0, 120) : ""),',
|
|
80
|
+
" },",
|
|
81
|
+
"};",
|
|
82
|
+
"",
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
40
85
|
/**
|
|
41
86
|
* Every scaffold-once file this config implies.
|
|
42
87
|
*
|
|
@@ -68,15 +113,26 @@ export function generateAstroidScaffoldFiles(config) {
|
|
|
68
113
|
files.push({
|
|
69
114
|
path: "src/schema.site.ts",
|
|
70
115
|
contents: [
|
|
71
|
-
"// Site-owned Drizzle tables
|
|
116
|
+
"// Site-owned Drizzle tables—the ones Astroid doesn't manage. Declare them",
|
|
72
117
|
"// here; the generated src/schema.ts re-exports everything from this file, so",
|
|
73
118
|
"// drizzle-kit sees them and the worker can import them. Empty by default.",
|
|
74
119
|
"//",
|
|
75
|
-
'//
|
|
120
|
+
'// For example: export const redirects = sqliteTable("redirects", { … });',
|
|
76
121
|
"export {};",
|
|
77
122
|
"",
|
|
78
123
|
].join("\n"),
|
|
79
124
|
});
|
|
125
|
+
// --- pages: the transform + reserved-slugs seam ---------------------------
|
|
126
|
+
if (config.pages?.hooks) {
|
|
127
|
+
files.push({ path: "src/pages-hooks.ts", contents: generateAstroidPagesHooks() });
|
|
128
|
+
}
|
|
129
|
+
// --- settings: the sanitize + read seam ------------------------------------
|
|
130
|
+
// Only when asked for (settings.hooks): the generated worker and the Actions
|
|
131
|
+
// surface both import it, so it must exist whenever either does. Scaffolded
|
|
132
|
+
// empty, which leaves the route exactly as it is without hooks.
|
|
133
|
+
if (config.settings?.hooks) {
|
|
134
|
+
files.push({ path: "src/settings-hooks.ts", contents: generateAstroidSettingsHooks() });
|
|
135
|
+
}
|
|
80
136
|
// --- the typed Astro Actions surface --------------------------------------
|
|
81
137
|
// Always: every project has editable pages, and the routes alone leave the
|
|
82
138
|
// Astro-native half of ADR 0001 unbuilt. Scaffold-once because it is meant to
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AstroidConfig, SectionKind } from "../config.js";
|
|
2
|
+
/** A stored section item: its `_type`, field values, and settings tokens. */
|
|
3
|
+
export interface AstroidSeedSection {
|
|
4
|
+
_type: SectionKind;
|
|
5
|
+
_settings?: Record<string, string>;
|
|
6
|
+
[field: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The sections a new project's home page starts with: the config's `sections`,
|
|
10
|
+
* or its archetype's default when the config doesn't list any, each with
|
|
11
|
+
* sample content.
|
|
12
|
+
*/
|
|
13
|
+
export declare function astroidHomeSeedSections(config: AstroidConfig): AstroidSeedSection[];
|
|
14
|
+
/**
|
|
15
|
+
* `seed/home.seed.sql`: one idempotent insert of the published `home` page,
|
|
16
|
+
* with the sections from {@link astroidHomeSeedSections}.
|
|
17
|
+
*
|
|
18
|
+
* Every value is escaped here, so a brand name with an apostrophe produces
|
|
19
|
+
* valid SQL. The template's token substitution can't escape, which is why the
|
|
20
|
+
* seed isn't a template file.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateAstroidHomeSeed(config: AstroidConfig): string;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The home page a new project seeds, built from its own config.
|
|
4
|
+
//
|
|
5
|
+
// This used to be a fixed file in the `create-astroid` template, and it seeded
|
|
6
|
+
// the marketing sections (`hero`, `featureGrid`, `cta`) for every archetype. A
|
|
7
|
+
// portfolio's config listed `hero`, `gallery`, `aboutIntro`, and `contact`, but
|
|
8
|
+
// its first page showed a feature grid and a call to action instead. Building
|
|
9
|
+
// the seed from the same section list the config holds means the two can't
|
|
10
|
+
// disagree.
|
|
11
|
+
//
|
|
12
|
+
// It isn't in `generateAstroidScaffoldFiles`, on purpose: a seed runs once,
|
|
13
|
+
// against a fresh database, so `astroid generate` has no reason to write it
|
|
14
|
+
// back into a project that deleted it.
|
|
15
|
+
import { ASTROID_ARCHETYPE_SECTIONS } from "../config.js";
|
|
16
|
+
/**
|
|
17
|
+
* Sample content for every section kind, keyed by `SectionKind`.
|
|
18
|
+
*
|
|
19
|
+
* A `Record` over the whole vocabulary rather than just the kinds an archetype
|
|
20
|
+
* uses today, so adding a section to the catalog is a compile error here until
|
|
21
|
+
* it has a sample. That keeps a site's `sections` override seedable, whatever
|
|
22
|
+
* it lists.
|
|
23
|
+
*
|
|
24
|
+
* The copy tells the owner how to replace it, because it's the first thing they
|
|
25
|
+
* see after signing in. Images are left empty: a new project has no media, and
|
|
26
|
+
* every section with an image renders without one.
|
|
27
|
+
*/
|
|
28
|
+
const SAMPLES = {
|
|
29
|
+
hero: (brand) => ({
|
|
30
|
+
heading: brand,
|
|
31
|
+
subheading: "Sign in, switch on edit mode, and type to change this text.",
|
|
32
|
+
ctaLabel: "Get in touch",
|
|
33
|
+
ctaHref: "/contact",
|
|
34
|
+
_settings: { colorway: "base", align: "center" },
|
|
35
|
+
}),
|
|
36
|
+
featureGrid: () => ({
|
|
37
|
+
heading: "What you offer",
|
|
38
|
+
items: [
|
|
39
|
+
{ title: "First thing", body: "Describe it here." },
|
|
40
|
+
{ title: "Second thing", body: "And this one." },
|
|
41
|
+
{ title: "Third thing", body: "And this one too." },
|
|
42
|
+
],
|
|
43
|
+
_settings: { colorway: "base" },
|
|
44
|
+
}),
|
|
45
|
+
cta: () => ({
|
|
46
|
+
heading: "Ready when you are",
|
|
47
|
+
body: "Replace this copy with your own.",
|
|
48
|
+
ctaLabel: "Contact us",
|
|
49
|
+
ctaHref: "/contact",
|
|
50
|
+
_settings: { colorway: "brand", align: "center" },
|
|
51
|
+
}),
|
|
52
|
+
gallery: () => ({
|
|
53
|
+
heading: "Selected work",
|
|
54
|
+
items: [],
|
|
55
|
+
_settings: { colorway: "base" },
|
|
56
|
+
}),
|
|
57
|
+
media: () => ({
|
|
58
|
+
heading: "A closer look",
|
|
59
|
+
_settings: { colorway: "base" },
|
|
60
|
+
}),
|
|
61
|
+
splitImage: () => ({
|
|
62
|
+
heading: "Tell your story",
|
|
63
|
+
body: "<p>Add an image and a few sentences about what makes this place yours.</p>",
|
|
64
|
+
_layout: "imageStart",
|
|
65
|
+
_settings: { colorway: "base" },
|
|
66
|
+
}),
|
|
67
|
+
steps: () => ({
|
|
68
|
+
heading: "How it works",
|
|
69
|
+
items: [
|
|
70
|
+
{ title: "Get in touch", body: "Say what you need." },
|
|
71
|
+
{ title: "Plan it together", body: "Agree on the details." },
|
|
72
|
+
{ title: "Done", body: "Describe the result here." },
|
|
73
|
+
],
|
|
74
|
+
_settings: { colorway: "base" },
|
|
75
|
+
}),
|
|
76
|
+
banner: () => ({
|
|
77
|
+
text: "Announce something here, like new hours or a seasonal special.",
|
|
78
|
+
_settings: { colorway: "brand", align: "center" },
|
|
79
|
+
}),
|
|
80
|
+
faq: () => ({
|
|
81
|
+
heading: "Questions",
|
|
82
|
+
items: [
|
|
83
|
+
{ question: "What's the first question people ask?", answer: "<p>Answer it here.</p>" },
|
|
84
|
+
{ question: "And the second?", answer: "<p>Answer that one here.</p>" },
|
|
85
|
+
],
|
|
86
|
+
_settings: { colorway: "base" },
|
|
87
|
+
}),
|
|
88
|
+
pricingTiers: () => ({
|
|
89
|
+
heading: "Pricing",
|
|
90
|
+
items: [
|
|
91
|
+
{
|
|
92
|
+
name: "Starter",
|
|
93
|
+
price: "$10",
|
|
94
|
+
period: "/mo",
|
|
95
|
+
features: [{ text: "Describe what's included" }],
|
|
96
|
+
featured: "no",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "Plus",
|
|
100
|
+
price: "$20",
|
|
101
|
+
period: "/mo",
|
|
102
|
+
features: [{ text: "Everything in Starter" }, { text: "And more" }],
|
|
103
|
+
featured: "yes",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
_settings: { colorway: "base" },
|
|
107
|
+
}),
|
|
108
|
+
testimonial: () => ({
|
|
109
|
+
quote: "Put a favorite thing a customer said about you here.",
|
|
110
|
+
attribution: "Alex",
|
|
111
|
+
role: "Customer",
|
|
112
|
+
_settings: { colorway: "base", align: "center" },
|
|
113
|
+
}),
|
|
114
|
+
aboutIntro: (brand) => ({
|
|
115
|
+
heading: `About ${brand}`,
|
|
116
|
+
body: "<p>Say who you are, what you make, and why it matters to you.</p>",
|
|
117
|
+
_settings: { colorway: "base" },
|
|
118
|
+
}),
|
|
119
|
+
productGrid: () => ({
|
|
120
|
+
heading: "Featured",
|
|
121
|
+
items: [
|
|
122
|
+
{ name: "First product", price: "$12" },
|
|
123
|
+
{ name: "Second product", price: "$18" },
|
|
124
|
+
{ name: "Third product", price: "$24" },
|
|
125
|
+
],
|
|
126
|
+
_settings: { colorway: "base" },
|
|
127
|
+
}),
|
|
128
|
+
locationHours: () => ({
|
|
129
|
+
heading: "Visit",
|
|
130
|
+
address: "Your street address\nCity, region, and postal code",
|
|
131
|
+
phone: "800-555-0100",
|
|
132
|
+
items: [
|
|
133
|
+
{ day: "Monday to Friday", hours: "8 AM to 6 PM" },
|
|
134
|
+
{ day: "Saturday and Sunday", hours: "9 AM to 4 PM" },
|
|
135
|
+
],
|
|
136
|
+
_settings: { colorway: "base" },
|
|
137
|
+
}),
|
|
138
|
+
contact: () => ({
|
|
139
|
+
heading: "Get in touch",
|
|
140
|
+
blurb: "Questions, orders, or just saying hello: send a message.",
|
|
141
|
+
_settings: { colorway: "secondary" },
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* The sections a new project's home page starts with: the config's `sections`,
|
|
146
|
+
* or its archetype's default when the config doesn't list any, each with
|
|
147
|
+
* sample content.
|
|
148
|
+
*/
|
|
149
|
+
export function astroidHomeSeedSections(config) {
|
|
150
|
+
const kinds = config.sections ?? ASTROID_ARCHETYPE_SECTIONS[config.archetype];
|
|
151
|
+
return kinds.map((kind) => ({ _type: kind, ...SAMPLES[kind](config.theme.name) }));
|
|
152
|
+
}
|
|
153
|
+
/** A SQLite string literal. Doubling `'` is the only escape SQLite needs. */
|
|
154
|
+
function sqlString(value) {
|
|
155
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* `seed/home.seed.sql`: one idempotent insert of the published `home` page,
|
|
159
|
+
* with the sections from {@link astroidHomeSeedSections}.
|
|
160
|
+
*
|
|
161
|
+
* Every value is escaped here, so a brand name with an apostrophe produces
|
|
162
|
+
* valid SQL. The template's token substitution can't escape, which is why the
|
|
163
|
+
* seed isn't a template file.
|
|
164
|
+
*/
|
|
165
|
+
export function generateAstroidHomeSeed(config) {
|
|
166
|
+
const brand = config.theme.name;
|
|
167
|
+
const sections = JSON.stringify(astroidHomeSeedSections(config), null, 2);
|
|
168
|
+
const body = `<p>Welcome to ${escapeHtml(brand)}. Sign in at <code>/login</code>, switch on edit mode, and change this text in place, then select Publish.</p>`;
|
|
169
|
+
return [
|
|
170
|
+
"-- Seed the editable home page (slug `home`) that src/pages/index.astro renders.",
|
|
171
|
+
"-- Idempotent. Apply once after migrations:",
|
|
172
|
+
"-- wrangler d1 execute DB --file seed/home.seed.sql --remote",
|
|
173
|
+
"-- (drop --remote for the local dev D1)",
|
|
174
|
+
"--",
|
|
175
|
+
"-- `sections` is the page-builder array: the same shape the on-canvas editor",
|
|
176
|
+
"-- reads and the server validates against the section catalog on write. Each item",
|
|
177
|
+
'-- is `{"_type": …, …fields, "_settings": {…}}`, and `_settings` holds TOKENS',
|
|
178
|
+
"-- (colorway, align), never CSS, so a re-theme needs no content change. The list",
|
|
179
|
+
"-- matches `sections` in astroid.config.ts.",
|
|
180
|
+
"--",
|
|
181
|
+
"-- Seeding it is deliberate: an empty array renders an empty page, and a blank",
|
|
182
|
+
"-- canvas is a worse first run than having something real to click on and edit.",
|
|
183
|
+
"--",
|
|
184
|
+
"-- Full-text search (the editor's Pages search) is kept in sync on PUBLISH, so a",
|
|
185
|
+
"-- row inserted with raw SQL like this isn't in the index until you either",
|
|
186
|
+
"-- publish an edit to it or backfill once with `POST /api/louise/pages/reindex`",
|
|
187
|
+
"-- (signed in). The page renders fine either way; only in-editor search is",
|
|
188
|
+
"-- affected.",
|
|
189
|
+
"--",
|
|
190
|
+
"-- To override just the <head> title and description (not the on-page H1), add",
|
|
191
|
+
"-- `seo_title` and `seo_description` columns below. `seo_title` is run through the",
|
|
192
|
+
"-- config's title template, which already appends the brand, so set the page",
|
|
193
|
+
'-- part only ("Pricing"), not "Pricing | Your brand".',
|
|
194
|
+
"INSERT OR IGNORE INTO pages (slug, title, body, sections, status, sort_order, created_at, updated_at)",
|
|
195
|
+
"VALUES (",
|
|
196
|
+
" 'home',",
|
|
197
|
+
` ${sqlString(brand)},`,
|
|
198
|
+
` ${sqlString(body)},`,
|
|
199
|
+
` json(${sqlString(sections)}),`,
|
|
200
|
+
" 'published',",
|
|
201
|
+
" 0,",
|
|
202
|
+
" unixepoch(),",
|
|
203
|
+
" unixepoch()",
|
|
204
|
+
");",
|
|
205
|
+
"",
|
|
206
|
+
].join("\n");
|
|
207
|
+
}
|
|
208
|
+
function escapeHtml(value) {
|
|
209
|
+
return value
|
|
210
|
+
.replaceAll("&", "&")
|
|
211
|
+
.replaceAll("<", "<")
|
|
212
|
+
.replaceAll(">", ">")
|
|
213
|
+
.replaceAll('"', """);
|
|
214
|
+
}
|
package/dist/pwa/generate.js
CHANGED
|
@@ -111,11 +111,11 @@ export function generateServiceWorker(config) {
|
|
|
111
111
|
return [
|
|
112
112
|
`// Service worker for the ${config.theme.name} PWA, scoped to ${pwa.scope}.`,
|
|
113
113
|
"//",
|
|
114
|
-
"// Generated by Astroid. Safe to edit
|
|
114
|
+
"// Generated by Astroid. Safe to edit—bump CACHE to invalidate everything",
|
|
115
115
|
"// on the next visit.",
|
|
116
116
|
"//",
|
|
117
117
|
"// What it deliberately never caches, and why:",
|
|
118
|
-
"// /api/* checkout, auth, and every Louise write
|
|
118
|
+
"// /api/* checkout, auth, and every Louise write—a cached POST",
|
|
119
119
|
"// response or a stale session is worse than being offline",
|
|
120
120
|
"// editor routes the studio must always be live",
|
|
121
121
|
"// ?louise URLs an edit-mode request; caching one would serve an editor a",
|
|
@@ -126,8 +126,8 @@ export function generateServiceWorker(config) {
|
|
|
126
126
|
...(pwa.offlineFallback
|
|
127
127
|
? [
|
|
128
128
|
"// A prerendered page with no session-specific markup. The scope root is",
|
|
129
|
-
"// the app SHELL, which on an auth-gated app is `Cache-Control: no-store
|
|
130
|
-
"//
|
|
129
|
+
"// the app SHELL, which on an auth-gated app is `Cache-Control: no-store`—",
|
|
130
|
+
"// so falling back to it serves either nothing or someone else's shell.",
|
|
131
131
|
`const OFFLINE = ${JSON.stringify(pwa.offlineFallback)};`,
|
|
132
132
|
]
|
|
133
133
|
: []),
|
|
@@ -165,7 +165,7 @@ export function generateServiceWorker(config) {
|
|
|
165
165
|
" );",
|
|
166
166
|
"}",
|
|
167
167
|
"",
|
|
168
|
-
"/** Hashed build output
|
|
168
|
+
"/** Hashed build output—the filename changes when the content does, so it",
|
|
169
169
|
" * can be cached forever without a staleness risk. */",
|
|
170
170
|
"function isImmutable(url) {",
|
|
171
171
|
" return url.pathname.startsWith('/_astro/') || url.pathname.startsWith('/icons/');",
|
|
@@ -181,7 +181,7 @@ export function generateServiceWorker(config) {
|
|
|
181
181
|
" // Cross-origin requests belong to whoever serves them.",
|
|
182
182
|
" if (url.origin !== self.location.origin) return;",
|
|
183
183
|
" if (isDynamic(url)) return;",
|
|
184
|
-
" // Outside the scope this worker has no business intercepting
|
|
184
|
+
" // Outside the scope this worker has no business intercepting—the rest of",
|
|
185
185
|
" // the site is CMS-edited and must stay live.",
|
|
186
186
|
" if (SCOPE !== '/' && !url.pathname.startsWith(SCOPE)) return;",
|
|
187
187
|
"",
|
|
@@ -245,7 +245,7 @@ export function generatePwaHeaders(config) {
|
|
|
245
245
|
return [
|
|
246
246
|
"",
|
|
247
247
|
"# The service worker must revalidate on every load, or a bad worker sticks",
|
|
248
|
-
"# around until its cache entry expires
|
|
248
|
+
"# around until its cache entry expires—and it controls every page in scope.",
|
|
249
249
|
`${base}/sw.js`,
|
|
250
250
|
" Cache-Control: no-cache",
|
|
251
251
|
"",
|
package/dist/queues/scaffold.js
CHANGED
|
@@ -36,7 +36,7 @@ const PROVIDERS = {
|
|
|
36
36
|
// Stripe's header carries a timestamp; the verifier rejects replays outside
|
|
37
37
|
// its tolerance, so it needs the current time.
|
|
38
38
|
call: 'verifyStripeSignature(raw, headers.get(HEADER) ?? "", secret, Math.floor(Date.now() / 1000))',
|
|
39
|
-
note: "Stripe's signature is timestamped
|
|
39
|
+
note: "Stripe's signature is timestamped—the verifier rejects replays outside a 5-minute tolerance.",
|
|
40
40
|
},
|
|
41
41
|
fourthwall: {
|
|
42
42
|
module: "louise-toolkit/commerce/fourthwall",
|
|
@@ -61,7 +61,7 @@ export function generateAstroidEnvBindings(config) {
|
|
|
61
61
|
if (providers.length === 0)
|
|
62
62
|
return "";
|
|
63
63
|
return [
|
|
64
|
-
" /** Queue producer
|
|
64
|
+
" /** Queue producer—verified webhooks + the cron re-sync (src/queue.ts). */",
|
|
65
65
|
' COMMERCE_QUEUE: Queue<import("astroidjs").AstroidQueueMessage>;',
|
|
66
66
|
// One secret SET per provider: a site running Stripe for invoicing and
|
|
67
67
|
// Fourthwall for the storefront talks to both, credentialed and signed
|
|
@@ -97,15 +97,15 @@ export function generateAstroidQueueSeam(config) {
|
|
|
97
97
|
// in rather than leaving it to be discovered on a busy day (#294).
|
|
98
98
|
const multiProvider = astroidCommerceProviders(config.commerce).length > 1;
|
|
99
99
|
return [
|
|
100
|
-
"// The queue consumer
|
|
100
|
+
"// The queue consumer—what each message actually does.",
|
|
101
101
|
"//",
|
|
102
102
|
"// Scaffolded once; yours to edit. `astroidQueueHandler` owns the dispatch",
|
|
103
103
|
"// (a periodic refresh and any catalog-affecting webhook trigger a re-sync;",
|
|
104
104
|
"// everything else acks as a no-op), so what's left here is what a refresh",
|
|
105
105
|
"// MEANS for this project.",
|
|
106
106
|
"//",
|
|
107
|
-
"// Throwing marks the message for retry. That's usually right
|
|
108
|
-
"// refresh means the site is serving stale data
|
|
107
|
+
"// Throwing marks the message for retry. That's usually right—a failed",
|
|
108
|
+
"// refresh means the site is serving stale data—and Cloudflare routes it to",
|
|
109
109
|
"// the DLQ once it exceeds max_retries (wrangler.jsonc).",
|
|
110
110
|
'import { astroidQueueHandler, type AstroidQueueMessage } from "astroidjs";',
|
|
111
111
|
"",
|
|
@@ -118,7 +118,7 @@ export function generateAstroidQueueSeam(config) {
|
|
|
118
118
|
? [
|
|
119
119
|
` // This project runs more than one commerce provider, and the catalog is`,
|
|
120
120
|
` // ${provider}'s. Scoping the refresh keeps the OTHER provider's webhooks from`,
|
|
121
|
-
` // triggering it
|
|
121
|
+
` // triggering it—Square alone emits an inventory event on every sale, which`,
|
|
122
122
|
` // unscoped would re-sync ${provider} once per transaction.`,
|
|
123
123
|
` catalogProvider: ${JSON.stringify(provider)},`,
|
|
124
124
|
]
|
|
@@ -143,7 +143,7 @@ export function generateAstroidQueueSeam(config) {
|
|
|
143
143
|
" // takes `retry: { attempts: 3 }`, backing off on 429/5xx inside every",
|
|
144
144
|
" // verb. It is off by default because a checkout route has a customer",
|
|
145
145
|
" // watching a spinner, and there a fast failure beats a slow one. Here",
|
|
146
|
-
" // the opposite holds
|
|
146
|
+
" // the opposite holds—a catalog push that gives up halfway is worse.",
|
|
147
147
|
]
|
|
148
148
|
: [
|
|
149
149
|
" // Nobody is watching this run, so ask for backoff on 429/5xx wherever",
|
|
@@ -158,7 +158,7 @@ export function generateAstroidQueueSeam(config) {
|
|
|
158
158
|
" // letting that escape is correct: an uncaught throw here marks the",
|
|
159
159
|
" // message for retry. Swallow it and the queue acks, the cron acks too,",
|
|
160
160
|
" // and the site serves a frozen catalog with nothing in `wrangler tail`.",
|
|
161
|
-
" // Partial failures don't throw
|
|
161
|
+
" // Partial failures don't throw—that's what `r.failed` above is for.",
|
|
162
162
|
" void env;",
|
|
163
163
|
" },",
|
|
164
164
|
" })(message);",
|
|
@@ -194,7 +194,7 @@ export function generateAstroidWebhookRoute(config, forProvider) {
|
|
|
194
194
|
`// ${p.note}`,
|
|
195
195
|
"//",
|
|
196
196
|
"// Unprovisioned (the secret is absent or still the placeholder) answers 503,",
|
|
197
|
-
"// which keeps the provider retrying
|
|
197
|
+
"// which keeps the provider retrying—so events delivered before you set the",
|
|
198
198
|
"// secret land afterwards instead of being lost.",
|
|
199
199
|
'import type { APIRoute } from "astro";',
|
|
200
200
|
'import { handleWebhook, readModuleSecret } from "astroidjs";',
|
|
@@ -54,7 +54,7 @@ export function generateAstroidEditSession(config) {
|
|
|
54
54
|
return [
|
|
55
55
|
"// The per-page live editing session Durable Object (ADR 0002 / #71).",
|
|
56
56
|
"//",
|
|
57
|
-
"// Scaffolded once and yours to edit
|
|
57
|
+
"// Scaffolded once and yours to edit—`persist` in particular. What should NOT",
|
|
58
58
|
"// change is the delegation: every handler forwards to the session object, and",
|
|
59
59
|
"// the session is built LAZILY. A Durable Object is re-instantiated after a",
|
|
60
60
|
"// hibernation wake, so a session captured in a field initializer would be",
|
|
@@ -91,7 +91,7 @@ export function generateAstroidEditSession(config) {
|
|
|
91
91
|
" // Guard the collection: only `pages` is realtime, and a stray target",
|
|
92
92
|
" // must never write into the wrong table.",
|
|
93
93
|
' if (target.slug !== "pages") return;',
|
|
94
|
-
" // The SAME merge-over-pending-draft path the fetch auto-save uses
|
|
94
|
+
" // The SAME merge-over-pending-draft path the fetch auto-save uses—",
|
|
95
95
|
" // one write path, so drafts/history/publish semantics are identical.",
|
|
96
96
|
" //",
|
|
97
97
|
" // No `bufferKv` here on purpose: the DO's alarm IS the coalescer for",
|
|
@@ -105,8 +105,8 @@ export function generateAstroidEditSession(config) {
|
|
|
105
105
|
" snapshot,",
|
|
106
106
|
" );",
|
|
107
107
|
" // A THROW (D1 down) propagates so the alarm keeps the snapshot dirty",
|
|
108
|
-
" // and retries. An `ok: false` is terminal
|
|
109
|
-
" // draft
|
|
108
|
+
" // and retries. An `ok: false` is terminal—a deleted row, an invalid",
|
|
109
|
+
" // draft—which a retry can't fix, so let the alarm clear it, but say",
|
|
110
110
|
" // so rather than dropping it silently.",
|
|
111
111
|
" if (!result.ok) {",
|
|
112
112
|
" console.warn(",
|
|
@@ -37,12 +37,43 @@ export declare function assertAstroidPageSections(config: AstroidConfig, data: R
|
|
|
37
37
|
*
|
|
38
38
|
* pagesRoute({ table: pages, resolveEditor, fields, ...astroidPagesWriteHooks(config) })
|
|
39
39
|
*/
|
|
40
|
-
|
|
40
|
+
/** The write context `pagesRoute` passes to a transform or validator. */
|
|
41
|
+
export interface AstroidPagesWriteContext {
|
|
42
|
+
operation: "create" | "update";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A site's own `pagesRoute` hooks, exported as `pagesHooks` from the
|
|
46
|
+
* scaffold-once `src/pages-hooks.ts` when `pages.hooks` is on.
|
|
47
|
+
*/
|
|
48
|
+
export interface AstroidPagesHooks {
|
|
49
|
+
/**
|
|
50
|
+
* Clean a page write before Astroid's section sanitize and validate run, for
|
|
51
|
+
* example to normalize the slug, clamp a title, or fill a new page's defaults.
|
|
52
|
+
* It gets only the allowlisted fields of the write.
|
|
53
|
+
*/
|
|
54
|
+
transform?: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
55
|
+
/**
|
|
56
|
+
* Reject a write, after both transforms and before Astroid's own section
|
|
57
|
+
* validation. Throw a `LouiseValidationError` for a 422 with per-field
|
|
58
|
+
* violations, for example when a required field is empty.
|
|
59
|
+
*/
|
|
60
|
+
validate?: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => void | Promise<void>;
|
|
61
|
+
/** Slugs to refuse on top of {@link ASTROID_RESERVED_SLUGS}, such as a path
|
|
62
|
+
* a site's own file route serves. */
|
|
63
|
+
reservedSlugs?: Iterable<string>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Slugs no page may take, on any site. Each one is a path Astro, Cloudflare,
|
|
67
|
+
* or Astroid serves before the catch-all page route, so a page saved under it
|
|
68
|
+
* would be unreachable, and nothing would say why. `pagesRoute` refuses them
|
|
69
|
+
* with a 422 instead.
|
|
70
|
+
*/
|
|
71
|
+
export declare const ASTROID_RESERVED_SLUGS: readonly string[];
|
|
72
|
+
export declare function astroidPagesWriteHooks(config: AstroidConfig, site?: AstroidPagesHooks): {
|
|
41
73
|
sanitize: (html: string) => string;
|
|
42
|
-
transform: (data: Record<string, unknown
|
|
43
|
-
validate: (data: Record<string, unknown>, ctx:
|
|
44
|
-
|
|
45
|
-
}) => Promise<void>;
|
|
74
|
+
transform: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => Promise<Record<string, unknown>>;
|
|
75
|
+
validate: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => Promise<void>;
|
|
76
|
+
reservedSlugs: string[];
|
|
46
77
|
};
|
|
47
78
|
export declare function astroidPagesCollection(config: AstroidConfig): CollectionConfig;
|
|
48
79
|
/**
|