astroidjs 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) 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/commerce/adapters.d.ts +60 -0
  10. package/dist/commerce/adapters.js +90 -0
  11. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  12. package/dist/commerce/checkout-scaffold.js +306 -0
  13. package/dist/commerce/checkout.d.ts +72 -0
  14. package/dist/commerce/checkout.js +124 -0
  15. package/dist/commerce/index.d.ts +8 -0
  16. package/dist/commerce/index.js +9 -0
  17. package/dist/commerce/loader.d.ts +71 -0
  18. package/dist/commerce/loader.js +90 -0
  19. package/dist/commerce/mirror.d.ts +67 -0
  20. package/dist/commerce/mirror.js +203 -0
  21. package/dist/commerce/roles.d.ts +38 -0
  22. package/dist/commerce/roles.js +93 -0
  23. package/dist/commerce/secrets.d.ts +74 -0
  24. package/dist/commerce/secrets.js +129 -0
  25. package/dist/commerce/sync.d.ts +86 -0
  26. package/dist/commerce/sync.js +154 -0
  27. package/dist/components/sections.d.ts +577 -0
  28. package/dist/components/sections.js +425 -0
  29. package/dist/config.d.ts +174 -12
  30. package/dist/config.js +43 -1
  31. package/dist/email/index.d.ts +4 -0
  32. package/dist/email/index.js +5 -0
  33. package/dist/email/inquiry.d.ts +33 -0
  34. package/dist/email/inquiry.js +63 -0
  35. package/dist/email/send.d.ts +120 -0
  36. package/dist/email/send.js +196 -0
  37. package/dist/email/templates.d.ts +24 -0
  38. package/dist/email/templates.js +184 -0
  39. package/dist/email/theme.d.ts +24 -0
  40. package/dist/email/theme.js +150 -0
  41. package/dist/errors.d.ts +14 -0
  42. package/dist/errors.js +17 -0
  43. package/dist/index.d.ts +14 -0
  44. package/dist/index.js +14 -0
  45. package/dist/map/index.d.ts +3 -0
  46. package/dist/map/index.js +4 -0
  47. package/dist/map/pmtiles.d.ts +92 -0
  48. package/dist/map/pmtiles.js +130 -0
  49. package/dist/map/scaffold.d.ts +29 -0
  50. package/dist/map/scaffold.js +212 -0
  51. package/dist/map/style.d.ts +58 -0
  52. package/dist/map/style.js +154 -0
  53. package/dist/portal/config.d.ts +26 -0
  54. package/dist/portal/config.js +50 -0
  55. package/dist/portal/guard.d.ts +48 -0
  56. package/dist/portal/guard.js +64 -0
  57. package/dist/portal/index.d.ts +5 -0
  58. package/dist/portal/index.js +6 -0
  59. package/dist/portal/nav.d.ts +26 -0
  60. package/dist/portal/nav.js +35 -0
  61. package/dist/portal/scaffold.d.ts +28 -0
  62. package/dist/portal/scaffold.js +140 -0
  63. package/dist/portal/session.d.ts +36 -0
  64. package/dist/portal/session.js +86 -0
  65. package/dist/portfolio/index.d.ts +1 -0
  66. package/dist/portfolio/index.js +4 -0
  67. package/dist/portfolio/scaffold.d.ts +9 -0
  68. package/dist/portfolio/scaffold.js +93 -0
  69. package/dist/project/actions.d.ts +3 -0
  70. package/dist/project/actions.js +106 -0
  71. package/dist/project/generate.d.ts +15 -0
  72. package/dist/project/generate.js +144 -2
  73. package/dist/project/index.d.ts +2 -0
  74. package/dist/project/index.js +2 -0
  75. package/dist/project/scaffold.d.ts +29 -0
  76. package/dist/project/scaffold.js +140 -0
  77. package/dist/pwa/generate.d.ts +49 -0
  78. package/dist/pwa/generate.js +218 -0
  79. package/dist/pwa/index.d.ts +1 -0
  80. package/dist/pwa/index.js +2 -0
  81. package/dist/queues/consumer.d.ts +29 -0
  82. package/dist/queues/consumer.js +37 -0
  83. package/dist/queues/index.d.ts +4 -0
  84. package/dist/queues/index.js +5 -0
  85. package/dist/queues/messages.d.ts +60 -0
  86. package/dist/queues/messages.js +71 -0
  87. package/dist/queues/scaffold.d.ts +44 -0
  88. package/dist/queues/scaffold.js +204 -0
  89. package/dist/queues/webhook.d.ts +60 -0
  90. package/dist/queues/webhook.js +81 -0
  91. package/dist/realtime/index.d.ts +1 -0
  92. package/dist/realtime/index.js +4 -0
  93. package/dist/realtime/scaffold.d.ts +30 -0
  94. package/dist/realtime/scaffold.js +159 -0
  95. package/dist/schema/collections.d.ts +41 -7
  96. package/dist/schema/collections.js +101 -12
  97. package/dist/schema/generate.js +10 -1
  98. package/dist/secrets.d.ts +54 -0
  99. package/dist/secrets.js +80 -0
  100. package/dist/security/index.d.ts +1 -0
  101. package/dist/security/index.js +2 -0
  102. package/dist/security/rate-rules.d.ts +21 -0
  103. package/dist/security/rate-rules.js +107 -0
  104. package/dist/seo/index.d.ts +3 -0
  105. package/dist/seo/index.js +4 -0
  106. package/dist/seo/resolve.d.ts +68 -0
  107. package/dist/seo/resolve.js +73 -0
  108. package/dist/seo/routes.d.ts +44 -0
  109. package/dist/seo/routes.js +104 -0
  110. package/dist/seo/structured-data.d.ts +51 -0
  111. package/dist/seo/structured-data.js +105 -0
  112. package/dist/status.d.ts +51 -0
  113. package/dist/status.js +113 -0
  114. package/dist/worker/generate.d.ts +18 -10
  115. package/dist/worker/generate.js +325 -37
  116. package/dist/worker/routes.d.ts +1 -1
  117. package/dist/worker/routes.js +42 -0
  118. package/dist/workflow/advance.d.ts +102 -0
  119. package/dist/workflow/advance.js +145 -0
  120. package/dist/workflow/config.d.ts +60 -0
  121. package/dist/workflow/config.js +73 -0
  122. package/dist/workflow/generate.d.ts +22 -0
  123. package/dist/workflow/generate.js +138 -0
  124. package/dist/workflow/index.d.ts +3 -0
  125. package/dist/workflow/index.js +4 -0
  126. package/package.json +21 -4
  127. package/src/components/Editable.astro +33 -9
  128. package/src/components/JustifiedGallery.astro +254 -0
  129. package/src/components/MediaSlot.astro +178 -0
  130. package/src/components/PortalShell.astro +80 -0
  131. package/src/components/RegisterSW.astro +45 -0
  132. package/src/components/Section.astro +101 -35
  133. package/src/components/Sections.astro +64 -0
  134. package/src/components/Seo.astro +57 -0
  135. package/src/components/StageBar.astro +137 -0
  136. package/src/components/StructuredData.astro +33 -0
  137. package/src/components/justify.ts +170 -0
  138. package/src/components/media-meta.ts +174 -0
  139. package/src/components/sections/AboutIntro.astro +46 -0
  140. package/src/components/sections/Banner.astro +31 -0
  141. package/src/components/sections/Contact.astro +22 -9
  142. package/src/components/sections/Cta.astro +33 -10
  143. package/src/components/sections/Faq.astro +50 -0
  144. package/src/components/sections/FeatureGrid.astro +40 -11
  145. package/src/components/sections/Gallery.astro +46 -0
  146. package/src/components/sections/Hero.astro +40 -12
  147. package/src/components/sections/LocationHours.astro +59 -0
  148. package/src/components/sections/Media.astro +44 -0
  149. package/src/components/sections/PricingTiers.astro +79 -0
  150. package/src/components/sections/ProductGrid.astro +73 -0
  151. package/src/components/sections/SplitImage.astro +61 -0
  152. package/src/components/sections/Steps.astro +58 -0
  153. package/src/components/sections/Testimonial.astro +51 -0
  154. package/src/components/sections.ts +452 -67
@@ -0,0 +1,425 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The section library's schema + render contract (ADR 0003 primitives, ADR 0005
4
+ // model).
5
+ //
6
+ // This file used to define a parallel universe: a `SectionProps` union
7
+ // discriminated on `kind`, with `colorway`/`align` as component props. Louise's
8
+ // actual model — the one the on-canvas editor and the write-time validator both
9
+ // read — is different in every particular, and it is the one that wins:
10
+ //
11
+ // • a section is a stored `SectionItem`: `{ _type, blocks?, _layout?,
12
+ // _settings?, ...fields }`. The discriminant is `_type`, not `kind`.
13
+ // • its shape is declared once as a `SectionDef` in a `SectionCatalog`, which
14
+ // is SCHEMA ONLY. The same object drives `mountSections` (the editor) and
15
+ // `validateSections` (the server), so a field can't be editable-but-invalid
16
+ // or validated-but-uneditable.
17
+ // • presentation choices are `_settings` / `_layout` **tokens**. Louise stores
18
+ // the token; the site maps it to CSS. That's why COLORWAY_CLASS below stays
19
+ // — it is exactly the site-owned half of that contract — while `colorway`
20
+ // stops being a prop and becomes a stored setting.
21
+ //
22
+ // ADR 0005 §2 names this file's job outright: "<Section> reads `_layout` /
23
+ // `_settings` and slots its children, and <Editable> already owns the
24
+ // `data-louise-*` marker contract, so a site author writes `<Editable
25
+ // field="heading">` and never hand-stamps the deeper path."
26
+ //
27
+ // Self-contained on purpose: this module ships as SOURCE (the `.astro` files
28
+ // beside it import it directly), so it must not reach back into astroid's built
29
+ // `src/*` — only siblings and external packages. The `louise-toolkit/content`
30
+ // import is TYPE-ONLY, so it erases at build and never drags the validator (or
31
+ // drizzle, which that entry pulls in) into a page bundle.
32
+ /**
33
+ * Colorway → daisyUI surface classes, keyed to the `Theme.colors` set
34
+ * (brand/secondary/tertiary) plus the neutral base.
35
+ *
36
+ * This map is the site-owned half of the token contract: Louise stores
37
+ * `_settings.colorway = "brand"` and never learns what that renders as, so a
38
+ * brand re-theme is a change here and no content rewrite anywhere.
39
+ */
40
+ export const COLORWAY_CLASS = {
41
+ brand: "bg-primary text-primary-content",
42
+ secondary: "bg-secondary text-secondary-content",
43
+ tertiary: "bg-accent text-accent-content",
44
+ base: "bg-base-100 text-base-content",
45
+ };
46
+ /** Content alignment → flex/text-alignment utilities. Same token contract. */
47
+ export const ALIGN_CLASS = {
48
+ start: "text-start items-start",
49
+ center: "text-center items-center",
50
+ end: "text-end items-end",
51
+ };
52
+ /** Resolve a colorway token to its class string (default: neutral base). */
53
+ export function colorwayClass(colorway = "base") {
54
+ return COLORWAY_CLASS[colorway] ?? COLORWAY_CLASS.base;
55
+ }
56
+ /** Resolve an alignment token to its class string (default: start). */
57
+ export function alignClass(align = "start") {
58
+ return ALIGN_CLASS[align] ?? ALIGN_CLASS.start;
59
+ }
60
+ /** Title-case a token for a picker label ("brand" → "Brand"). */
61
+ const label = (token) => token.charAt(0).toUpperCase() + token.slice(1);
62
+ /**
63
+ * Turn a token→class map into `select` options.
64
+ *
65
+ * Deriving them is the point: the options a picker offers and the tokens the
66
+ * site can actually render are then the same list by construction, so adding a
67
+ * colorway is one edit to `COLORWAY_CLASS` rather than an edit plus a
68
+ * remembered second edit here that silently offers a token nothing maps.
69
+ */
70
+ const tokenOptions = (map) => Object.keys(map).map((value) => ({ value, label: label(value) }));
71
+ /**
72
+ * The shared `_settings` every section in the catalog accepts.
73
+ *
74
+ * These are closed token sets, declared as `select` (#272) so the inspector
75
+ * renders a picker and an unknown token is rejected on write. They used to be
76
+ * `text` with the valid values stuffed into `placeholder` — which meant a typo
77
+ * wasn't a validation error at all, just a silent fallback to the default
78
+ * inside `colorwayClass` at render time.
79
+ */
80
+ export const SECTION_SETTINGS = {
81
+ colorway: {
82
+ type: "select",
83
+ label: "Colorway",
84
+ inline: false,
85
+ options: tokenOptions(COLORWAY_CLASS),
86
+ // An opaque hint — the schema layer doesn't know what a swatch looks like;
87
+ // a renderer that doesn't support it just shows a normal picker.
88
+ display: "swatch",
89
+ },
90
+ align: {
91
+ type: "select",
92
+ label: "Alignment",
93
+ inline: false,
94
+ options: tokenOptions(ALIGN_CLASS),
95
+ },
96
+ };
97
+ /** Read a `_settings` token as a string, or a fallback. Tolerant by design: a
98
+ * stored setting is untrusted JSON, and a bad token should degrade to the
99
+ * default rather than throw during render. */
100
+ export function setting(item, key, fallback) {
101
+ const value = item._settings?.[key];
102
+ return typeof value === "string" && value !== "" ? value : fallback;
103
+ }
104
+ /** Read a section field as a string (the common case for text/richText). */
105
+ export function field(item, key) {
106
+ const value = item[key];
107
+ return typeof value === "string" ? value : undefined;
108
+ }
109
+ /** Read an `array` field as a list of objects, or `[]`. Stored arrays are
110
+ * untrusted, so a non-array or a scalar entry is dropped rather than rendered. */
111
+ export function list(item, key) {
112
+ const value = item[key];
113
+ if (!Array.isArray(value))
114
+ return [];
115
+ return value.filter((v) => typeof v === "object" && v !== null && !Array.isArray(v));
116
+ }
117
+ /** Read a string off an array item (see {@link list}). */
118
+ export function itemField(row, key) {
119
+ const value = row[key];
120
+ return typeof value === "string" ? value : undefined;
121
+ }
122
+ /**
123
+ * Alt text for an image, resolving the ASSET-LEVEL fallback.
124
+ *
125
+ * The precedence is the whole reason `<Sections>` does its media lookup: a
126
+ * per-usage `alt` on the section wins, because the same photo means something
127
+ * different in a hero than in a thumbnail strip — but when there isn't one, the
128
+ * alt an editor typed once in the media library is used. That's what makes
129
+ * fixing alt text a single edit that propagates everywhere the asset appears,
130
+ * instead of a hunt through every page that embeds it.
131
+ *
132
+ * Returns `""` rather than undefined when neither exists: an image with no
133
+ * description is decorative as far as a screen reader is concerned, and an
134
+ * empty alt says exactly that. A missing attribute makes it read the filename.
135
+ */
136
+ export function mediaAlt(mediaMeta, src, override) {
137
+ if (override !== undefined && override !== "")
138
+ return override;
139
+ return (src ? mediaMeta?.[src]?.alt : undefined) ?? "";
140
+ }
141
+ /** Caption for an image, same precedence as {@link mediaAlt}. Undefined when
142
+ * there is none — a missing caption renders nothing, unlike a missing alt. */
143
+ export function mediaCaption(mediaMeta, src, override) {
144
+ if (override !== undefined && override !== "")
145
+ return override;
146
+ return src ? mediaMeta?.[src]?.caption : undefined;
147
+ }
148
+ // --- The catalog -----------------------------------------------------------
149
+ // Schema only. Each entry declares what an editor can change and how it is
150
+ // validated; the matching `.astro` component owns every pixel.
151
+ /**
152
+ * Every section type Astroid ships.
153
+ *
154
+ * `satisfies` rather than a `: SectionCatalog` annotation, and it matters:
155
+ * `SectionCatalog` is `Record<string, SectionDef>`, so annotating would widen
156
+ * `keyof typeof` to `string` and throw away the literal keys. Those keys are
157
+ * the project's whole section vocabulary — `SectionKind` is derived from them
158
+ * (config.ts), `isRenderableSection` narrows to them, and `<Section>` indexes
159
+ * its component map with them. Annotate this and all three silently degrade to
160
+ * "any string", which is how the dispatcher lost its type safety once already.
161
+ */
162
+ export const astroidSectionCatalog = {
163
+ hero: {
164
+ label: "Hero",
165
+ icon: "hero",
166
+ fields: {
167
+ heading: { type: "text", label: "Heading", validation: (r) => r.required().max(120) },
168
+ subheading: { type: "textarea", label: "Subheading" },
169
+ // A link URL is something you can't point at on the page, so it is not
170
+ // inline — it belongs in the inspector, which is what `inline: false` says.
171
+ ctaLabel: { type: "text", label: "Button label" },
172
+ ctaHref: { type: "text", label: "Button link", inline: false },
173
+ },
174
+ settings: SECTION_SETTINGS,
175
+ },
176
+ featureGrid: {
177
+ label: "Feature grid",
178
+ icon: "grid",
179
+ fields: {
180
+ heading: { type: "text", label: "Heading" },
181
+ items: {
182
+ type: "array",
183
+ label: "Features",
184
+ itemLabel: "Feature",
185
+ itemFields: {
186
+ title: { type: "text", label: "Title", validation: (r) => r.required() },
187
+ body: { type: "textarea", label: "Body" },
188
+ },
189
+ },
190
+ },
191
+ settings: SECTION_SETTINGS,
192
+ },
193
+ cta: {
194
+ label: "Call to action",
195
+ icon: "cta",
196
+ fields: {
197
+ heading: { type: "text", label: "Heading", validation: (r) => r.required() },
198
+ body: { type: "textarea", label: "Body" },
199
+ ctaLabel: { type: "text", label: "Button label", validation: (r) => r.required() },
200
+ ctaHref: { type: "text", label: "Button link", inline: false },
201
+ },
202
+ settings: SECTION_SETTINGS,
203
+ },
204
+ // --- media ---------------------------------------------------------------
205
+ // Every `image` field is declared as such rather than as text, which is what
206
+ // lets `collectSectionMediaUrls` find it and the page resolve alt/caption in
207
+ // one lookup. A per-usage `alt` sits beside each one as an OVERRIDE: left
208
+ // empty it falls back to the asset's own alt, so an editor fixes it once in
209
+ // the media library (see `mediaAlt`).
210
+ gallery: {
211
+ label: "Gallery",
212
+ icon: "gallery",
213
+ fields: {
214
+ heading: { type: "text", label: "Heading" },
215
+ items: {
216
+ type: "array",
217
+ label: "Images",
218
+ itemLabel: "Image",
219
+ itemFields: {
220
+ image: { type: "image", label: "Image" },
221
+ alt: { type: "text", label: "Alt text (overrides the asset's)", inline: false },
222
+ caption: { type: "text", label: "Caption" },
223
+ href: { type: "text", label: "Links to", inline: false },
224
+ },
225
+ },
226
+ },
227
+ settings: SECTION_SETTINGS,
228
+ },
229
+ media: {
230
+ label: "Media",
231
+ icon: "image",
232
+ fields: {
233
+ heading: { type: "text", label: "Heading" },
234
+ image: { type: "image", label: "Image" },
235
+ alt: { type: "text", label: "Alt text (overrides the asset's)", inline: false },
236
+ caption: { type: "text", label: "Caption" },
237
+ },
238
+ settings: SECTION_SETTINGS,
239
+ },
240
+ splitImage: {
241
+ label: "Split image",
242
+ icon: "columns",
243
+ fields: {
244
+ heading: { type: "text", label: "Heading", validation: (r) => r.required() },
245
+ body: { type: "richText", label: "Body" },
246
+ image: { type: "image", label: "Image" },
247
+ alt: { type: "text", label: "Alt text (overrides the asset's)", inline: false },
248
+ ctaLabel: { type: "text", label: "Button label" },
249
+ ctaHref: { type: "text", label: "Button link", inline: false },
250
+ },
251
+ // Which side the image sits on is a LAYOUT, not a setting: it's a named
252
+ // arrangement of the same content, which is exactly what `_layout` is for.
253
+ layouts: { imageStart: { label: "Image left" }, imageEnd: { label: "Image right" } },
254
+ settings: SECTION_SETTINGS,
255
+ },
256
+ // --- structured copy -----------------------------------------------------
257
+ steps: {
258
+ label: "Steps",
259
+ icon: "list-ordered",
260
+ fields: {
261
+ heading: { type: "text", label: "Heading" },
262
+ items: {
263
+ type: "array",
264
+ label: "Steps",
265
+ itemLabel: "Step",
266
+ itemFields: {
267
+ title: { type: "text", label: "Title", validation: (r) => r.required() },
268
+ body: { type: "textarea", label: "Body" },
269
+ },
270
+ },
271
+ },
272
+ settings: SECTION_SETTINGS,
273
+ },
274
+ banner: {
275
+ label: "Banner",
276
+ icon: "megaphone",
277
+ fields: {
278
+ text: { type: "text", label: "Text", validation: (r) => r.required().max(200) },
279
+ ctaLabel: { type: "text", label: "Button label" },
280
+ ctaHref: { type: "text", label: "Button link", inline: false },
281
+ },
282
+ settings: SECTION_SETTINGS,
283
+ },
284
+ faq: {
285
+ label: "FAQ",
286
+ icon: "help",
287
+ fields: {
288
+ heading: { type: "text", label: "Heading" },
289
+ items: {
290
+ type: "array",
291
+ label: "Questions",
292
+ itemLabel: "Question",
293
+ itemFields: {
294
+ question: { type: "text", label: "Question", validation: (r) => r.required() },
295
+ // richText, not textarea: an answer routinely wants a link.
296
+ answer: { type: "richText", label: "Answer" },
297
+ },
298
+ },
299
+ },
300
+ settings: SECTION_SETTINGS,
301
+ },
302
+ pricingTiers: {
303
+ label: "Pricing tiers",
304
+ icon: "tag",
305
+ fields: {
306
+ heading: { type: "text", label: "Heading" },
307
+ items: {
308
+ type: "array",
309
+ label: "Tiers",
310
+ itemLabel: "Tier",
311
+ itemFields: {
312
+ name: { type: "text", label: "Name", validation: (r) => r.required() },
313
+ price: { type: "text", label: "Price" },
314
+ period: { type: "text", label: "Period (e.g. /mo)" },
315
+ // A list of strings isn't expressible — array items are objects — so
316
+ // each feature is a one-field row. That also leaves room to add an
317
+ // `included` flag later without a data migration.
318
+ features: {
319
+ type: "array",
320
+ label: "Features",
321
+ itemLabel: "Feature",
322
+ itemFields: { text: { type: "text", label: "Feature" } },
323
+ },
324
+ ctaLabel: { type: "text", label: "Button label" },
325
+ ctaHref: { type: "text", label: "Button link", inline: false },
326
+ featured: {
327
+ type: "select",
328
+ label: "Highlight this tier",
329
+ inline: false,
330
+ options: [
331
+ { value: "yes", label: "Yes" },
332
+ { value: "no", label: "No" },
333
+ ],
334
+ },
335
+ },
336
+ },
337
+ },
338
+ settings: SECTION_SETTINGS,
339
+ },
340
+ testimonial: {
341
+ label: "Testimonial",
342
+ icon: "quote",
343
+ fields: {
344
+ quote: { type: "textarea", label: "Quote", validation: (r) => r.required() },
345
+ attribution: { type: "text", label: "Who said it" },
346
+ role: { type: "text", label: "Their role" },
347
+ image: { type: "image", label: "Portrait" },
348
+ alt: { type: "text", label: "Alt text (overrides the asset's)", inline: false },
349
+ },
350
+ settings: SECTION_SETTINGS,
351
+ },
352
+ aboutIntro: {
353
+ label: "About intro",
354
+ icon: "user",
355
+ fields: {
356
+ heading: { type: "text", label: "Heading", validation: (r) => r.required() },
357
+ body: { type: "richText", label: "Body" },
358
+ image: { type: "image", label: "Image" },
359
+ alt: { type: "text", label: "Alt text (overrides the asset's)", inline: false },
360
+ },
361
+ settings: SECTION_SETTINGS,
362
+ },
363
+ // --- module-adjacent -----------------------------------------------------
364
+ productGrid: {
365
+ label: "Product grid",
366
+ icon: "shopping-bag",
367
+ fields: {
368
+ heading: { type: "text", label: "Heading" },
369
+ // Deliberately hand-authored rows rather than a live catalog read. A
370
+ // section is stored content, and the commerce mirror is a separate
371
+ // concern with its own loader — a site that wants the live catalog renders
372
+ // `readCatalog` in its own page, not through the page-builder.
373
+ items: {
374
+ type: "array",
375
+ label: "Products",
376
+ itemLabel: "Product",
377
+ itemFields: {
378
+ name: { type: "text", label: "Name", validation: (r) => r.required() },
379
+ price: { type: "text", label: "Price" },
380
+ image: { type: "image", label: "Image" },
381
+ alt: { type: "text", label: "Alt text (overrides the asset's)", inline: false },
382
+ href: { type: "text", label: "Links to", inline: false },
383
+ },
384
+ },
385
+ },
386
+ settings: SECTION_SETTINGS,
387
+ },
388
+ locationHours: {
389
+ label: "Location & hours",
390
+ icon: "map-pin",
391
+ fields: {
392
+ heading: { type: "text", label: "Heading" },
393
+ address: { type: "textarea", label: "Address" },
394
+ phone: { type: "text", label: "Phone" },
395
+ items: {
396
+ type: "array",
397
+ label: "Hours",
398
+ itemLabel: "Day",
399
+ itemFields: {
400
+ day: { type: "text", label: "Day", validation: (r) => r.required() },
401
+ hours: { type: "text", label: "Hours" },
402
+ },
403
+ },
404
+ },
405
+ settings: SECTION_SETTINGS,
406
+ },
407
+ contact: {
408
+ label: "Contact",
409
+ icon: "mail",
410
+ fields: {
411
+ heading: { type: "text", label: "Heading" },
412
+ blurb: { type: "textarea", label: "Blurb" },
413
+ },
414
+ settings: SECTION_SETTINGS,
415
+ },
416
+ };
417
+ /**
418
+ * Does this `_type` have a shipped component? Narrows the untrusted `_type` of a
419
+ * stored item, so `<Sections>` can skip an unknown one instead of rendering a
420
+ * hole. (Unknown types are legitimate mid-migration, and `validateSections`
421
+ * already rejects them on write.)
422
+ */
423
+ export function isRenderableSection(type) {
424
+ return Object.hasOwn(astroidSectionCatalog, type);
425
+ }
package/dist/config.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import type { RateRule } from "louise-toolkit/security";
2
+ import type { CatalogMirrorConfig } from "./commerce/mirror.js";
3
+ import type { astroidSectionCatalog } from "./components/sections.js";
4
+ import type { PortalRoute } from "./portal/guard.js";
5
+ import type { PwaConfig } from "./pwa/generate.js";
1
6
  /**
2
7
  * The starting shape the front-end takes. Not a fork — each archetype is a preset
3
8
  * of defaults (which sections/modules are on, nav shape) that the site then tunes.
@@ -7,17 +12,55 @@
7
12
  */
8
13
  export type Archetype = "marketing" | "storefront" | "wholesale" | "portfolio";
9
14
  /**
10
- * The section vocabulary — the editable home page is an ordered list of these, top
11
- * to bottom. Each maps to a themeable component in the Astroid section library.
12
- * Drawn from real usage across the target sites (annotated below).
15
+ * The section vocabulary — the editable home page is an ordered list of these,
16
+ * top to bottom.
17
+ *
18
+ * DERIVED from the section catalog, not hand-written (#277). It used to be its
19
+ * own union, and the two drifted in both directions: this named four kinds with
20
+ * no catalog entry and no component (`marquee`, `featured`, `story`, `visit`),
21
+ * while omitting eight that were real and renderable. A scaffold's config then
22
+ * listed sections that could never render, and nothing type-checked the gap.
23
+ *
24
+ * A type-only import, so the derivation adds no runtime dependency: `config.ts`
25
+ * is loaded by the `create-astroid` CLI, and this keeps its import graph
26
+ * exactly as it was.
27
+ */
28
+ export type SectionKind = keyof typeof astroidSectionCatalog;
29
+ /**
30
+ * Each archetype's default home-page sections.
31
+ *
32
+ * Lives here, in TypeScript, rather than in `create-astroid`'s plain JS — the
33
+ * other half of #277. As a JS object literal it could name a section that
34
+ * didn't exist and nothing would say so; typed against {@link SectionKind}
35
+ * (itself derived from the catalog) a stale name is a compile error, and CI
36
+ * type-checks this package.
37
+ *
38
+ * The four kinds this used to name — `marquee`, `featured`, `story`, `visit` —
39
+ * had no catalog entry or component and could never render. Each is replaced by
40
+ * the real section that does its job: a marquee is a `banner`, curated picks
41
+ * are a `productGrid`, a brand-origin block is `aboutIntro`, and "visit" is
42
+ * exactly `locationHours`.
13
43
  */
14
- export type SectionKind = "hero" | "marquee" | "featureGrid" | "featured" | "productGrid" | "gallery" | "story" | "visit" | "cta" | "testimonial" | "contact";
44
+ export declare const ASTROID_ARCHETYPE_SECTIONS: Record<Archetype, SectionKind[]>;
15
45
  /**
16
46
  * Optional capabilities the site switches on. Pluggable, not core — a portfolio
17
- * site runs none of the commerce ones. `orderTracking` is shared across both
18
- * coffee brands, so it's first-class but still opt-in.
47
+ * site runs none of the commerce ones.
48
+ *
49
+ * **Every value here is read by something.** The union used to also name
50
+ * `orderTracking`, `subscriptions`, `giftCards`, and `privateLabel`, none of
51
+ * which had a single consumer anywhere in the package: setting one type-checked,
52
+ * passed validation, and did nothing at all — no scaffold, no CSP origin, no
53
+ * rate rule, no table. A config surface that accepts a setting it ignores is
54
+ * worse than a smaller one, because the only way to discover the truth is to
55
+ * deploy and notice the absence.
56
+ *
57
+ * They are removed rather than left as TODOs. `orderTracking` in particular has
58
+ * a real implementation waiting — `src/workflow/` is the ghostfire order tracker,
59
+ * generalized — but it is reached through `defineWorkflow`, not this flag, and
60
+ * pretending otherwise is what made the flag misleading. Re-add each one in the
61
+ * change that wires it.
19
62
  */
20
- export type ModuleKind = "orderTracking" | "subscriptions" | "giftCards" | "wholesaleInquiry" | "privateLabel";
63
+ export type ModuleKind = "map" | "pwa" | "realtime" | "wholesaleInquiry";
21
64
  /** Commerce backend — mirrors Louise's provider set (louise-toolkit/commerce). */
22
65
  export type CommerceProvider = "stripe" | "square" | "fourthwall";
23
66
  export interface Theme {
@@ -40,14 +83,125 @@ export interface Theme {
40
83
  }
41
84
  export interface Portal {
42
85
  enabled: boolean;
43
- /** Require a session to view the whole site (Meg Bowen's gated preview), not
44
- * just the account area. Default `false`. */
86
+ /**
87
+ * @deprecated NOT IMPLEMENTED — `defineAstroid` throws if this is set.
88
+ *
89
+ * It was meant to require a session for the whole site (a pre-launch client
90
+ * gallery), not just the account area, but nothing ever read it: the guard
91
+ * table is built from {@link Portal.routes} and `portalGuard` allows any
92
+ * unmatched path. Until it's wired, gate the site by naming the prefixes in
93
+ * `routes` — that is the mechanism this would have been sugar for.
94
+ */
45
95
  gated?: boolean;
46
- /** Modules exposed inside the account area (e.g. `orderTracking`). */
96
+ /** Modules exposed inside the account area (e.g. `wholesaleInquiry`, which
97
+ * adds the inquiries table even on an archetype that wouldn't have one). */
47
98
  features?: ModuleKind[];
99
+ /**
100
+ * Roles a portal account can hold, first being the default for a new account.
101
+ * Default `["customer"]`. These are the portal's OWN roles — entirely separate
102
+ * from the editor's `admin`, because the two auth instances don't share a
103
+ * user table.
104
+ */
105
+ roles?: string[];
106
+ /**
107
+ * Route guard table: everything under `prefix` needs one of `roles`. Matched
108
+ * in order, first match wins. Defaults to `/portal` + `/api/portal` for any
109
+ * signed-in portal user.
110
+ */
111
+ routes?: PortalRoute[];
112
+ /**
113
+ * Where a portal user lands, per role — used to bounce someone who reached an
114
+ * area they don't belong in. Default `/portal` for everyone.
115
+ */
116
+ home?: Record<string, string>;
117
+ /**
118
+ * Allow public sign-up. Default `false`: both consuming sites provision portal
119
+ * accounts by hand, and a portal is usually for people you already know.
120
+ */
121
+ signUp?: boolean;
48
122
  }
49
123
  export interface CommerceConfig {
50
- provider: CommerceProvider;
124
+ /**
125
+ * Shorthand for a single-provider site. Assigns the provider to a role it can
126
+ * actually serve — `square`/`fourthwall` become the storefront, `stripe`
127
+ * becomes invoicing (its client has no catalog API).
128
+ */
129
+ provider?: CommerceProvider;
130
+ /** Catalog, cart, checkout. Needs a provider with a catalog API. */
131
+ storefront?: CommerceProvider;
132
+ /**
133
+ * Invoices for work that isn't a catalog item — commissions, originals.
134
+ * Independent of `storefront`: themidwestartist.com runs Stripe here and
135
+ * Fourthwall as the storefront, because neither can do the other's job.
136
+ */
137
+ invoicing?: CommerceProvider;
138
+ /** The catalog mirror's shape — its mode, table name, and owned columns. */
139
+ catalog?: CatalogMirrorConfig;
140
+ }
141
+ export interface QueuesConfig {
142
+ /**
143
+ * Force the queue consumer + cron on or off. Defaults to on whenever
144
+ * `commerce` is configured: a commerce provider means webhooks, and a webhook
145
+ * you process inline is a webhook you drop when the provider times out.
146
+ */
147
+ enabled?: boolean;
148
+ /**
149
+ * Cron for the safety-net re-sync, or `false` for none. Webhooks get missed —
150
+ * a provider outage, a deploy mid-delivery, a DLQ'd message — and without a
151
+ * periodic re-sync the site serves stale data until someone notices. Default
152
+ * hourly.
153
+ */
154
+ cron?: string | false;
155
+ /** Deliveries before Cloudflare routes a message to the DLQ. Default 5. */
156
+ maxRetries?: number;
157
+ /** Messages per consumer invocation. Default 10. */
158
+ maxBatchSize?: number;
159
+ /** Seconds the consumer waits to fill a batch. Default 30. */
160
+ maxBatchTimeout?: number;
161
+ }
162
+ export interface SeoConfig {
163
+ /**
164
+ * `<title>` template, `%s` standing in for the page title. Applied only when
165
+ * a page supplies its own title, so the home page reads "Acme Coffee" and not
166
+ * "Acme Coffee | Acme Coffee". Default `"%s | <site name>"`.
167
+ */
168
+ titleTemplate?: string;
169
+ /**
170
+ * schema.org `@type` for the business node in the JSON-LD graph. Defaults to
171
+ * the archetype's broad type (see `ARCHETYPE_BUSINESS_TYPE`); set a more
172
+ * specific subtype whenever you know one — `"CafeOrCoffeeShop"`,
173
+ * `"ArtGallery"`, `"HomeAndConstructionBusiness"` — since a narrower type is
174
+ * strictly better for rich results.
175
+ */
176
+ businessType?: string;
177
+ /** `@handle` for Twitter/X card attribution. */
178
+ twitterHandle?: string;
179
+ /** Open Graph locale, e.g. `"en_US"`. */
180
+ locale?: string;
181
+ }
182
+ export interface SecurityConfig {
183
+ /**
184
+ * Extra rate-limit rules for surfaces Astroid doesn't know about, and the seam
185
+ * for overriding a default budget. These are matched BEFORE the derived
186
+ * defaults (first match wins), so declaring a rule for a path Astroid already
187
+ * covers replaces that one rule rather than the whole set.
188
+ */
189
+ rateRules?: RateRule[];
190
+ /**
191
+ * Extra origins to allow in the generated Content-Security-Policy, merged with
192
+ * the ones Astroid derives from the enabled modules. Add a host here when you
193
+ * pull in a third party Astroid can't see (a chat widget, a video embed).
194
+ */
195
+ cspOrigins?: CspOrigins;
196
+ }
197
+ /** Per-directive origin lists contributed to the CSP. */
198
+ export interface CspOrigins {
199
+ script?: string[];
200
+ frame?: string[];
201
+ connect?: string[];
202
+ font?: string[];
203
+ img?: string[];
204
+ worker?: string[];
51
205
  }
52
206
  export interface DeployConfig {
53
207
  platform: "cloudflare";
@@ -75,6 +229,14 @@ export interface AstroidConfig {
75
229
  portal?: Portal;
76
230
  /** Commerce backend. */
77
231
  commerce?: CommerceConfig;
232
+ /** Queue consumer + cron safety net. Defaults on when `commerce` is set. */
233
+ queues?: QueuesConfig;
234
+ /** Title template, structured-data type, and social-card attribution. */
235
+ seo?: SeoConfig;
236
+ /** Additions to the rate-limit rules + CSP origins Astroid derives. */
237
+ security?: SecurityConfig;
238
+ /** Installable-app settings. Only read when `modules` includes `"pwa"`. */
239
+ pwa?: PwaConfig;
78
240
  deploy?: DeployConfig;
79
241
  }
80
242
  /**
@@ -89,7 +251,7 @@ export interface AstroidConfig {
89
251
  * key: "coracle",
90
252
  * archetype: "storefront",
91
253
  * theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
92
- * sections: ["hero", "marquee", "featured", "productGrid", "visit"],
254
+ * sections: ["hero", "banner", "productGrid", "locationHours", "contact"],
93
255
  * commerce: { provider: "square" },
94
256
  * deploy: { platform: "cloudflare" },
95
257
  * });