astroidjs 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/auth/index.d.ts +27 -0
  10. package/dist/auth/index.js +59 -0
  11. package/dist/commerce/adapters.d.ts +60 -0
  12. package/dist/commerce/adapters.js +90 -0
  13. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  14. package/dist/commerce/checkout-scaffold.js +306 -0
  15. package/dist/commerce/checkout.d.ts +72 -0
  16. package/dist/commerce/checkout.js +124 -0
  17. package/dist/commerce/index.d.ts +8 -0
  18. package/dist/commerce/index.js +9 -0
  19. package/dist/commerce/loader.d.ts +71 -0
  20. package/dist/commerce/loader.js +90 -0
  21. package/dist/commerce/mirror.d.ts +69 -0
  22. package/dist/commerce/mirror.js +214 -0
  23. package/dist/commerce/roles.d.ts +38 -0
  24. package/dist/commerce/roles.js +93 -0
  25. package/dist/commerce/secrets.d.ts +74 -0
  26. package/dist/commerce/secrets.js +129 -0
  27. package/dist/commerce/sync.d.ts +86 -0
  28. package/dist/commerce/sync.js +154 -0
  29. package/dist/components/sections.d.ts +577 -0
  30. package/dist/components/sections.js +425 -0
  31. package/dist/config.d.ts +239 -12
  32. package/dist/config.js +49 -1
  33. package/dist/email/index.d.ts +4 -0
  34. package/dist/email/index.js +5 -0
  35. package/dist/email/inquiry.d.ts +33 -0
  36. package/dist/email/inquiry.js +63 -0
  37. package/dist/email/send.d.ts +120 -0
  38. package/dist/email/send.js +196 -0
  39. package/dist/email/templates.d.ts +24 -0
  40. package/dist/email/templates.js +184 -0
  41. package/dist/email/theme.d.ts +24 -0
  42. package/dist/email/theme.js +150 -0
  43. package/dist/errors.d.ts +14 -0
  44. package/dist/errors.js +17 -0
  45. package/dist/index.d.ts +15 -0
  46. package/dist/index.js +15 -0
  47. package/dist/map/index.d.ts +3 -0
  48. package/dist/map/index.js +4 -0
  49. package/dist/map/pmtiles.d.ts +92 -0
  50. package/dist/map/pmtiles.js +130 -0
  51. package/dist/map/scaffold.d.ts +29 -0
  52. package/dist/map/scaffold.js +212 -0
  53. package/dist/map/style.d.ts +58 -0
  54. package/dist/map/style.js +154 -0
  55. package/dist/portal/config.d.ts +26 -0
  56. package/dist/portal/config.js +56 -0
  57. package/dist/portal/guard.d.ts +54 -0
  58. package/dist/portal/guard.js +64 -0
  59. package/dist/portal/index.d.ts +5 -0
  60. package/dist/portal/index.js +6 -0
  61. package/dist/portal/nav.d.ts +26 -0
  62. package/dist/portal/nav.js +35 -0
  63. package/dist/portal/scaffold.d.ts +28 -0
  64. package/dist/portal/scaffold.js +140 -0
  65. package/dist/portal/session.d.ts +36 -0
  66. package/dist/portal/session.js +86 -0
  67. package/dist/portfolio/index.d.ts +1 -0
  68. package/dist/portfolio/index.js +4 -0
  69. package/dist/portfolio/scaffold.d.ts +9 -0
  70. package/dist/portfolio/scaffold.js +93 -0
  71. package/dist/project/actions.d.ts +3 -0
  72. package/dist/project/actions.js +121 -0
  73. package/dist/project/generate.d.ts +15 -0
  74. package/dist/project/generate.js +144 -2
  75. package/dist/project/index.d.ts +2 -0
  76. package/dist/project/index.js +2 -0
  77. package/dist/project/scaffold.d.ts +29 -0
  78. package/dist/project/scaffold.js +166 -0
  79. package/dist/pwa/generate.d.ts +49 -0
  80. package/dist/pwa/generate.js +218 -0
  81. package/dist/pwa/index.d.ts +1 -0
  82. package/dist/pwa/index.js +2 -0
  83. package/dist/queues/consumer.d.ts +29 -0
  84. package/dist/queues/consumer.js +37 -0
  85. package/dist/queues/index.d.ts +4 -0
  86. package/dist/queues/index.js +5 -0
  87. package/dist/queues/messages.d.ts +60 -0
  88. package/dist/queues/messages.js +71 -0
  89. package/dist/queues/scaffold.d.ts +44 -0
  90. package/dist/queues/scaffold.js +204 -0
  91. package/dist/queues/webhook.d.ts +60 -0
  92. package/dist/queues/webhook.js +81 -0
  93. package/dist/realtime/index.d.ts +1 -0
  94. package/dist/realtime/index.js +4 -0
  95. package/dist/realtime/scaffold.d.ts +30 -0
  96. package/dist/realtime/scaffold.js +159 -0
  97. package/dist/schema/collections.d.ts +41 -7
  98. package/dist/schema/collections.js +110 -12
  99. package/dist/schema/framework.js +5 -0
  100. package/dist/schema/generate.js +17 -1
  101. package/dist/secrets.d.ts +54 -0
  102. package/dist/secrets.js +80 -0
  103. package/dist/security/index.d.ts +1 -0
  104. package/dist/security/index.js +2 -0
  105. package/dist/security/rate-rules.d.ts +21 -0
  106. package/dist/security/rate-rules.js +110 -0
  107. package/dist/seo/index.d.ts +3 -0
  108. package/dist/seo/index.js +4 -0
  109. package/dist/seo/resolve.d.ts +68 -0
  110. package/dist/seo/resolve.js +73 -0
  111. package/dist/seo/routes.d.ts +44 -0
  112. package/dist/seo/routes.js +104 -0
  113. package/dist/seo/structured-data.d.ts +51 -0
  114. package/dist/seo/structured-data.js +105 -0
  115. package/dist/status.d.ts +51 -0
  116. package/dist/status.js +113 -0
  117. package/dist/worker/generate.d.ts +18 -10
  118. package/dist/worker/generate.js +353 -42
  119. package/dist/worker/routes.d.ts +1 -1
  120. package/dist/worker/routes.js +42 -0
  121. package/dist/workflow/advance.d.ts +102 -0
  122. package/dist/workflow/advance.js +145 -0
  123. package/dist/workflow/config.d.ts +60 -0
  124. package/dist/workflow/config.js +73 -0
  125. package/dist/workflow/generate.d.ts +22 -0
  126. package/dist/workflow/generate.js +138 -0
  127. package/dist/workflow/index.d.ts +3 -0
  128. package/dist/workflow/index.js +4 -0
  129. package/package.json +21 -4
  130. package/src/components/Editable.astro +33 -9
  131. package/src/components/JustifiedGallery.astro +254 -0
  132. package/src/components/MediaSlot.astro +178 -0
  133. package/src/components/PortalShell.astro +80 -0
  134. package/src/components/RegisterSW.astro +45 -0
  135. package/src/components/Section.astro +101 -35
  136. package/src/components/Sections.astro +64 -0
  137. package/src/components/Seo.astro +57 -0
  138. package/src/components/StageBar.astro +137 -0
  139. package/src/components/StructuredData.astro +33 -0
  140. package/src/components/justify.ts +170 -0
  141. package/src/components/media-meta.ts +174 -0
  142. package/src/components/sections/AboutIntro.astro +46 -0
  143. package/src/components/sections/Banner.astro +31 -0
  144. package/src/components/sections/Contact.astro +22 -9
  145. package/src/components/sections/Cta.astro +33 -10
  146. package/src/components/sections/Faq.astro +50 -0
  147. package/src/components/sections/FeatureGrid.astro +40 -11
  148. package/src/components/sections/Gallery.astro +46 -0
  149. package/src/components/sections/Hero.astro +40 -12
  150. package/src/components/sections/LocationHours.astro +59 -0
  151. package/src/components/sections/Media.astro +44 -0
  152. package/src/components/sections/PricingTiers.astro +79 -0
  153. package/src/components/sections/ProductGrid.astro +73 -0
  154. package/src/components/sections/SplitImage.astro +61 -0
  155. package/src/components/sections/Steps.astro +58 -0
  156. package/src/components/sections/Testimonial.astro +51 -0
  157. package/src/components/sections.ts +452 -67
@@ -5,13 +5,25 @@
5
5
  // Astroid decides WHICH collections and fields exist (its opinions); Louise's
6
6
  // codegen decides HOW they become D1 tables. Dependency flows one way — this
7
7
  // imports `louise-toolkit/content`, never the reverse.
8
- // `content/define` rather than the `content` barrel: the barrel eagerly pulls the
9
- // codegen/localApi/validation chunks, and those import drizzle-orm for real — an
10
- // *optional* peer of louise-toolkit, so importing it here forced a package on
11
- // consumers who only ever describe collections. This entry is the drizzle-free
12
- // half (see louise-toolkit/src/core/content/define.ts).
8
+ // `content/define` and `content/sections` rather than the `content` barrel: the
9
+ // barrel eagerly pulls the codegen/localApi/validation chunks, and those import
10
+ // drizzle-orm for real — an *optional* peer of louise-toolkit, so importing it
11
+ // here would force a package on consumers who only DESCRIBE content (e.g.
12
+ // create-astroid's schema generators, which call this function but never run the
13
+ // beforeChange hook below). Both entries are drizzle-free: `content/define` for
14
+ // the config types/builders, and `content/sections` for the write-time section
15
+ // validators. That second entry is what the Rule-evaluator split
16
+ // (louise-toolkit/src/core/content/rule.ts) added, so this hook can import the
17
+ // validators STATICALLY instead of the dynamic `import("louise-toolkit/content")`
18
+ // it used to need to keep the CLI's graph drizzle-free.
13
19
  import { defineCollection, } from "louise-toolkit/content/define";
20
+ import { assertValidSections, sanitizeSectionsRichText } from "louise-toolkit/content/sections";
14
21
  import { sanitizeRichHtml } from "louise-toolkit/security";
22
+ // The catalog is the single declaration of what a section IS — the same object
23
+ // the on-canvas editor mounts with and this hook validates against. It lives
24
+ // beside the components (it ships as source for them) and is imported here so
25
+ // the two can't drift.
26
+ import { astroidSectionCatalog } from "../components/sections.js";
15
27
  /**
16
28
  * The opinionated `pages` collection — the EDITABLE page fields, versioned
17
29
  * drafts, and full-text search. Keyed to the same names as Louise's `pagesColumns`
@@ -22,12 +34,89 @@ import { sanitizeRichHtml } from "louise-toolkit/security";
22
34
  * Validated by `defineCollection` at build time, so a malformed field shape throws
23
35
  * here rather than at codegen.
24
36
  */
37
+ /** The media base a `pages` write sanitizes rich content against (config, `/media` default). */
38
+ function pageMediaBase(config) {
39
+ return config.deploy?.mediaBase ?? "/media";
40
+ }
41
+ /**
42
+ * The section catalog a `pages` write is validated + sanitized against: the
43
+ * site's own (`config.sectionCatalog`) when it registered bespoke sections, else
44
+ * Astroid's built-in vocabulary. This is what lets a site with its own section
45
+ * designs (coracle's 13) keep the same write contract as a stock Astroid site.
46
+ */
47
+ function resolveSectionCatalog(config) {
48
+ return config.sectionCatalog ?? astroidSectionCatalog;
49
+ }
50
+ /**
51
+ * Return a copy of a `pages` write payload with its `sections` rich-text fields
52
+ * sanitized against the project media base — a no-op when the write carries no
53
+ * `sections`. Pure; leaves every other field (and a partial PATCH's absent ones)
54
+ * untouched.
55
+ *
56
+ * Exported because two write paths need it: the collection's `beforeChange` hook
57
+ * below, AND the raw `pagesRoute` (which does not run collection hooks — see
58
+ * {@link astroidPagesWriteHooks}).
59
+ */
60
+ export function sanitizeAstroidPageSections(config, data) {
61
+ if (data.sections === undefined)
62
+ return data;
63
+ const mediaBase = pageMediaBase(config);
64
+ const sections = sanitizeSectionsRichText(data.sections, resolveSectionCatalog(config), (html) => sanitizeRichHtml(html, { mediaBase }));
65
+ return { ...data, sections };
66
+ }
67
+ /**
68
+ * Validate the (already-sanitized) `sections` of a `pages` write against the
69
+ * catalog, throwing `LouiseValidationError` — an unknown `_type`, a field of the
70
+ * wrong shape, or a setting outside its declared options is rejected with a 422
71
+ * carrying the per-field violations. A no-op when the write carries no
72
+ * `sections`, so a partial PATCH of other fields isn't spuriously validated.
73
+ */
74
+ export async function assertAstroidPageSections(config, data, operation = "update") {
75
+ if (data.sections === undefined)
76
+ return;
77
+ await assertValidSections(resolveSectionCatalog(config), data.sections, {
78
+ operation,
79
+ mediaBase: pageMediaBase(config),
80
+ });
81
+ }
82
+ /**
83
+ * The write-time hooks the raw `pagesRoute` (louise-toolkit/editor) needs to
84
+ * enforce the same section contract as the draft path.
85
+ *
86
+ * `pagesRoute` writes straight to the table and — unlike `versionsRoute` — takes
87
+ * no collection config, so it never runs the `beforeChange` hook below. Left
88
+ * bare (as it was), a direct `POST` / `PATCH /api/louise/pages/:id` persists an
89
+ * unknown section `_type`, a setting outside its options, or unsanitized section
90
+ * rich text: exactly what the hook exists to stop, silently missing from the one
91
+ * route the on-canvas *structural* edits flow through. `<Sections>` then skips
92
+ * the bad `_type`, so the section just vanishes with no error anywhere.
93
+ *
94
+ * These wire the SAME sanitize + validate the hook uses into `pagesRoute`'s
95
+ * `sanitize` / `transform` / `validate` seams, so both write paths enforce one
96
+ * contract. Spread into the route config:
97
+ *
98
+ * pagesRoute({ table: pages, resolveEditor, fields, ...astroidPagesWriteHooks(config) })
99
+ */
100
+ export function astroidPagesWriteHooks(config) {
101
+ const mediaBase = pageMediaBase(config);
102
+ return {
103
+ // `body` is a richField, so it goes through pagesRoute's own sanitize seam —
104
+ // with the project media base, matching the hook rather than the toolkit
105
+ // default sanitizer that knows no media base.
106
+ sanitize: (html) => sanitizeRichHtml(html, { mediaBase }),
107
+ // `sections` is not a richField, so it's sanitized here in the transform,
108
+ // which pagesRoute runs BEFORE validate — the hook's sanitize-then-validate
109
+ // order.
110
+ transform: (data) => sanitizeAstroidPageSections(config, data),
111
+ validate: (data, ctx) => assertAstroidPageSections(config, data, ctx.operation),
112
+ };
113
+ }
25
114
  export function astroidPagesCollection(config) {
26
115
  // The `body` is rich HTML edited in place (`<Editable type="richtext">`) and
27
116
  // staged as a draft, so sanitize it on every write — never store raw HTML. A
28
117
  // pasted `<img>` pointing off-origin (a hotlink) is dropped: body images must
29
118
  // live in the media library. Mirrors the reference site's pages-collection hook.
30
- const mediaBase = config.deploy?.mediaBase ?? "/media";
119
+ const mediaBase = pageMediaBase(config);
31
120
  const fields = {};
32
121
  fields.slug = { type: "text", required: true };
33
122
  fields.title = { type: "text", required: true };
@@ -38,19 +127,28 @@ export function astroidPagesCollection(config) {
38
127
  fields.ogImage = { type: "text" };
39
128
  fields.noindex = { type: "checkbox" };
40
129
  fields.sortOrder = { type: "number" };
41
- // Structured page-builder blocks (the editable home) — deep-validated against
42
- // the section catalog on write in a later slice.
130
+ // Structured page-builder blocks (the editable home), deep-validated against
131
+ // the section catalog on write — see the beforeChange hook below.
43
132
  fields.sections = { type: "json" };
44
133
  return defineCollection({
45
134
  slug: "pages",
46
135
  fields,
47
136
  hooks: {
48
137
  beforeChange: [
49
- ({ data }) => {
50
- if (typeof data.body === "string") {
51
- return { ...data, body: sanitizeRichHtml(data.body, { mediaBase }) };
138
+ async ({ data }) => {
139
+ let next = data;
140
+ if (typeof next.body === "string") {
141
+ next = { ...next, body: sanitizeRichHtml(next.body, { mediaBase }) };
52
142
  }
53
- return data;
143
+ // Sanitize BEFORE validating: a richText field stores HTML, and
144
+ // validating the raw value would pass content the sanitizer is about
145
+ // to change. Same order as the body above. Both steps are shared with
146
+ // the raw pagesRoute (see astroidPagesWriteHooks) so the two write
147
+ // paths can't diverge — the sanitize throws nothing, the assert throws
148
+ // LouiseValidationError → 422 with per-field violations.
149
+ next = sanitizeAstroidPageSections(config, next);
150
+ await assertAstroidPageSections(config, next, "update");
151
+ return next;
54
152
  },
55
153
  ],
56
154
  },
@@ -9,6 +9,11 @@
9
9
  * wholesale-inquiry module. Shared by table selection (here) and route selection
10
10
  * (the worker route plan). */
11
11
  export function capturesInquiries(config) {
12
+ // Explicit override wins — a site whose inquiry surface is a bespoke section
13
+ // (coracle's custom `contactForm`) can't be detected from the built-in
14
+ // vocabulary, so it says so directly.
15
+ if (typeof config.inquiries === "boolean")
16
+ return config.inquiries;
12
17
  const wantsWholesale = (mods) => (mods ?? []).includes("wholesaleInquiry");
13
18
  return ((config.sections ?? []).includes("contact") ||
14
19
  wantsWholesale(config.modules) ||
@@ -10,6 +10,7 @@
10
10
  // `generateSchemaSource`: the caller (the Astroid CLI, a later slice) writes the
11
11
  // result to disk and formats it. Import ordering matches the site's schema.ts so
12
12
  // a formatter never re-flags the generated file.
13
+ import { generateCatalogTable } from "../commerce/mirror.js";
13
14
  import { astroidFrameworkTables } from "./framework.js";
14
15
  /**
15
16
  * Generate the TypeScript source of a site's Drizzle schema from an Astroid
@@ -22,10 +23,17 @@ export function generateAstroidSchema(config) {
22
23
  // pagesColumns is spread into `pages`; the framework tables are re-exported.
23
24
  // One import brings them all in (matching the site) so nothing is unused.
24
25
  const dbImports = [...framework, "pagesColumns"].sort();
26
+ const catalog = generateCatalogTable(config);
27
+ // Only import the column builders the emitted source actually uses — an
28
+ // unused import is a lint error in the project we're generating into, and a
29
+ // missing one (`real`, from the catalog's price/sortOrder) is a type error.
30
+ const drizzleImports = ["integer", "sqliteTable", "text"];
31
+ if (catalog?.includes("real("))
32
+ drizzleImports.push("real");
25
33
  return [
26
34
  "// Generated by astroidjs — do not hand-edit.",
27
35
  "// Source: your defineAstroid config.",
28
- 'import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";',
36
+ `import { ${drizzleImports.sort().join(", ")} } from "drizzle-orm/sqlite-core";`,
29
37
  'import { collectionVersionsTable, type JsonValue } from "louise-toolkit/content";',
30
38
  `import { ${dbImports.join(", ")} } from "louise-toolkit/db";`,
31
39
  "",
@@ -42,7 +50,15 @@ export function generateAstroidSchema(config) {
42
50
  "// JSON blob — so the table only needs the slug.",
43
51
  'export const pagesVersions = collectionVersionsTable({ slug: "pages", fields: {} });',
44
52
  "",
53
+ ...(catalog ? [catalog] : []),
45
54
  `export { ${framework.join(", ")} };`,
46
55
  "",
56
+ "// Site-owned tables (the ones Astroid doesn't manage): a project declares",
57
+ "// its own Drizzle tables in src/schema.site.ts and they're re-exported here",
58
+ "// so drizzle-kit sees them and the generated worker can import them. The file",
59
+ "// is scaffold-once — empty until a project adds a table — so this re-export is",
60
+ "// always safe.",
61
+ 'export * from "./schema.site.js";',
62
+ "",
47
63
  ].join("\n");
48
64
  }
@@ -0,0 +1,54 @@
1
+ import { type SecretSource } from "louise-toolkit/security";
2
+ export type { SecretSource };
3
+ /**
4
+ * The placeholder every Astroid scaffold seeds its unprovisioned secrets with.
5
+ * Reading it back means "not configured yet", never a credential — the value is
6
+ * deliberately loud so it is obvious in a Secrets Store listing or a log line.
7
+ *
8
+ * It matches the sentinel `louise-toolkit`'s Turnstile gate already recognizes,
9
+ * so the captcha pair follows the same convention as every Astroid module.
10
+ */
11
+ export declare const ASTROID_SECRET_PLACEHOLDER = "DUMMY_REPLACE_ME";
12
+ /**
13
+ * Read one secret under the Astroid convention: `null` unless it holds a real,
14
+ * non-placeholder value. Thin by design — the reason to call this rather than
15
+ * `readSecret` directly is that it binds Astroid's sentinel for you.
16
+ */
17
+ export declare function readModuleSecret(source: SecretSource): Promise<string | null>;
18
+ /** The resolved secret set for one module, plus the gate derived from it. */
19
+ export interface ModuleSecrets<K extends string> {
20
+ /**
21
+ * True when EVERY secret the module declared resolved to a real value. The
22
+ * module's `isConfigured()` should be exactly this — partial provisioning is
23
+ * treated as dormant, since a half-configured integration fails at the worst
24
+ * possible moment (mid-checkout) rather than at boot.
25
+ */
26
+ configured: boolean;
27
+ /** Each declared secret's resolved value, or `null` where unprovisioned. */
28
+ values: Record<K, string | null>;
29
+ /** The still-unprovisioned names, in declaration order — the "why not" list. */
30
+ missing: K[];
31
+ }
32
+ /**
33
+ * Resolve a module's secrets in one pass.
34
+ *
35
+ * ```ts
36
+ * const secrets = await resolveModuleSecrets({
37
+ * SQUARE_ACCESS_TOKEN: env.SQUARE_ACCESS_TOKEN,
38
+ * SQUARE_WEBHOOK_SECRET: env.SQUARE_WEBHOOK_SECRET,
39
+ * });
40
+ * if (!secrets.configured) return simulatedCheckout(); // dormant path
41
+ * ```
42
+ *
43
+ * Reads run concurrently: a Secrets Store `.get()` is a real await, and a module
44
+ * with four secrets should not pay for four sequential round-trips on the
45
+ * request path.
46
+ */
47
+ export declare function resolveModuleSecrets<K extends string>(sources: Record<K, SecretSource>): Promise<ModuleSecrets<K>>;
48
+ /**
49
+ * A one-line, human-readable status for a module — what `astroid doctor`, a dev
50
+ * server banner, or a health endpoint should print. Naming the missing secrets
51
+ * is the whole point: "commerce is off" sends someone reading source, "commerce
52
+ * is dormant — set SQUARE_ACCESS_TOKEN" does not.
53
+ */
54
+ export declare function describeModuleStatus<K extends string>(module: string, status: ModuleSecrets<K>): string;
@@ -0,0 +1,80 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The dormant-until-provisioned convention.
4
+ //
5
+ // Astroid's optional modules are opt-in at the CONFIG level but not at the
6
+ // account level: switching `commerce` on in `defineAstroid` must not require a
7
+ // Square account before `pnpm dev` will boot. So every module here follows one
8
+ // rule — a module whose secrets are unprovisioned is DORMANT: it renders, it
9
+ // serves, it says out loud that it is simulated, and it never calls upstream
10
+ // with a dummy credential.
11
+ //
12
+ // The mechanism is `readSecret` from `louise-toolkit/security` (a secret that is
13
+ // absent / unreadable / empty / a placeholder reads as `null`). What lives here
14
+ // is the *convention* over it, which is Astroid's opinion, not the toolkit's:
15
+ //
16
+ // 1. ONE sentinel — `ASTROID_SECRET_PLACEHOLDER` — seeded by `create-astroid`
17
+ // into every secret a scaffold declares, so a fresh clone has a complete,
18
+ // valid binding set and zero real credentials.
19
+ // 2. `resolveModuleSecrets` collapses a module's whole secret set into one
20
+ // `configured` gate plus the list of what's still missing, so a module's
21
+ // `isConfigured()` and its "why not" message come from the same read.
22
+ //
23
+ // This mirrors what all three consuming sites converged on independently.
24
+ import { readSecret } from "louise-toolkit/security";
25
+ /**
26
+ * The placeholder every Astroid scaffold seeds its unprovisioned secrets with.
27
+ * Reading it back means "not configured yet", never a credential — the value is
28
+ * deliberately loud so it is obvious in a Secrets Store listing or a log line.
29
+ *
30
+ * It matches the sentinel `louise-toolkit`'s Turnstile gate already recognizes,
31
+ * so the captcha pair follows the same convention as every Astroid module.
32
+ */
33
+ export const ASTROID_SECRET_PLACEHOLDER = "DUMMY_REPLACE_ME";
34
+ /**
35
+ * Read one secret under the Astroid convention: `null` unless it holds a real,
36
+ * non-placeholder value. Thin by design — the reason to call this rather than
37
+ * `readSecret` directly is that it binds Astroid's sentinel for you.
38
+ */
39
+ export function readModuleSecret(source) {
40
+ return readSecret(source, { placeholder: ASTROID_SECRET_PLACEHOLDER });
41
+ }
42
+ /**
43
+ * Resolve a module's secrets in one pass.
44
+ *
45
+ * ```ts
46
+ * const secrets = await resolveModuleSecrets({
47
+ * SQUARE_ACCESS_TOKEN: env.SQUARE_ACCESS_TOKEN,
48
+ * SQUARE_WEBHOOK_SECRET: env.SQUARE_WEBHOOK_SECRET,
49
+ * });
50
+ * if (!secrets.configured) return simulatedCheckout(); // dormant path
51
+ * ```
52
+ *
53
+ * Reads run concurrently: a Secrets Store `.get()` is a real await, and a module
54
+ * with four secrets should not pay for four sequential round-trips on the
55
+ * request path.
56
+ */
57
+ export async function resolveModuleSecrets(sources) {
58
+ const names = Object.keys(sources);
59
+ const resolved = await Promise.all(names.map((name) => readModuleSecret(sources[name])));
60
+ const values = {};
61
+ const missing = [];
62
+ names.forEach((name, i) => {
63
+ const value = resolved[i] ?? null;
64
+ values[name] = value;
65
+ if (value === null)
66
+ missing.push(name);
67
+ });
68
+ return { configured: missing.length === 0, values, missing };
69
+ }
70
+ /**
71
+ * A one-line, human-readable status for a module — what `astroid doctor`, a dev
72
+ * server banner, or a health endpoint should print. Naming the missing secrets
73
+ * is the whole point: "commerce is off" sends someone reading source, "commerce
74
+ * is dormant — set SQUARE_ACCESS_TOKEN" does not.
75
+ */
76
+ export function describeModuleStatus(module, status) {
77
+ if (status.configured)
78
+ return `${module}: configured`;
79
+ return `${module}: dormant (simulated) — unprovisioned secret(s): ${status.missing.join(", ")}`;
80
+ }
@@ -0,0 +1 @@
1
+ export { ASTROID_CHECKOUT_PATH, ASTROID_PORTAL_BASE_PATH, astroidRateRules, type RateRule, } from "./rate-rules.js";
@@ -0,0 +1,2 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ export { ASTROID_CHECKOUT_PATH, ASTROID_PORTAL_BASE_PATH, astroidRateRules, } from "./rate-rules.js";
@@ -0,0 +1,21 @@
1
+ import type { RateRule } from "louise-toolkit/security";
2
+ import type { AstroidConfig } from "../config.js";
3
+ export type { RateRule };
4
+ /**
5
+ * Base path the customer/portal Better Auth instance mounts at — its own
6
+ * handler, separate from the editor's `/api/auth`. Fixed by Astroid so the rate
7
+ * rules, the middleware, and the portal routes can't drift apart.
8
+ */
9
+ export declare const ASTROID_PORTAL_BASE_PATH = "/api/portal-auth";
10
+ /** Path the commerce module's checkout POSTs to. */
11
+ export declare const ASTROID_CHECKOUT_PATH = "/api/checkout";
12
+ /**
13
+ * The rule set for a project, derived from its config: the editor sign-in
14
+ * surface always, the portal's credential surfaces when a portal is enabled, and
15
+ * checkout when commerce is configured.
16
+ *
17
+ * Rules are matched first-wins, and `security.rateRules` from the config are
18
+ * placed FIRST — so a site tightens or loosens any default by declaring its own
19
+ * rule for that path, rather than losing the whole set to override one budget.
20
+ */
21
+ export declare function astroidRateRules(config: AstroidConfig): RateRule[];
@@ -0,0 +1,110 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Rate-limit rules as data.
4
+ //
5
+ // The limiter mechanism lives in `louise-toolkit/security` and is deliberately
6
+ // unopinionated: which routes, and which budgets, are policy. But the policy
7
+ // turned out not to vary. All three consuming sites independently wrote the same
8
+ // `RateRule[]` — the same public POST surfaces, the same 10-minute windows,
9
+ // budgets within a factor of one of each other — differing only where the site
10
+ // had a surface the others didn't (a portal, a checkout). That is a default, not
11
+ // a per-site decision, so Astroid derives the whole set from the config.
12
+ //
13
+ // What's in scope: the public, UNAUTHENTICATED POST surfaces. Editor endpoints
14
+ // (`/api/louise/*`) are session-gated and stay out on purpose — a limiter that
15
+ // can lock the owner out of their own studio is worse than the abuse it stops.
16
+ // The contact form is also absent by design: it's a worker route with its own
17
+ // per-form limiter, and worker routes are matched before Astro's middleware ever
18
+ // runs, so a rule here would never fire.
19
+ /**
20
+ * Base path the customer/portal Better Auth instance mounts at — its own
21
+ * handler, separate from the editor's `/api/auth`. Fixed by Astroid so the rate
22
+ * rules, the middleware, and the portal routes can't drift apart.
23
+ */
24
+ export const ASTROID_PORTAL_BASE_PATH = "/api/portal-auth";
25
+ /** Path the commerce module's checkout POSTs to. */
26
+ export const ASTROID_CHECKOUT_PATH = "/api/checkout";
27
+ /** Ten minutes. Every default budget uses this window — long enough that a
28
+ * burst can't wait it out, short enough that a false positive self-heals. */
29
+ const WINDOW = 600;
30
+ const exact = (path) => (p) => p === path;
31
+ /**
32
+ * The rule set for a project, derived from its config: the editor sign-in
33
+ * surface always, the portal's credential surfaces when a portal is enabled, and
34
+ * checkout when commerce is configured.
35
+ *
36
+ * Rules are matched first-wins, and `security.rateRules` from the config are
37
+ * placed FIRST — so a site tightens or loosens any default by declaring its own
38
+ * rule for that path, rather than losing the whole set to override one budget.
39
+ */
40
+ export function astroidRateRules(config) {
41
+ const rules = [...(config.security?.rateRules ?? [])];
42
+ // Magic-link sign-in is the email-bombing target: without a cap, anyone who
43
+ // knows an editor's address can trigger unbounded sign-in mail (their inbox,
44
+ // your Email + Worker spend). Tightest budget in the set.
45
+ rules.push({
46
+ name: "magic-link",
47
+ method: "POST",
48
+ match: exact("/api/auth/sign-in/magic-link"),
49
+ limit: 5,
50
+ windowSec: WINDOW,
51
+ });
52
+ // Everything else Better Auth serves (passkey challenges, sign-out, callbacks)
53
+ // behind a looser catch-all. Ordered after the specific rule above, which
54
+ // matters: `matchRateRule` takes the first match.
55
+ rules.push({
56
+ name: "auth",
57
+ method: "POST",
58
+ match: (p) => p.startsWith("/api/auth/"),
59
+ limit: 30,
60
+ windowSec: WINDOW,
61
+ });
62
+ if (config.portal?.enabled) {
63
+ // The portal's mount is configurable (a site may already ship a second
64
+ // instance at its own path), so the credential-surface rules must track the
65
+ // resolved base path, not the default constant — mirrors `astroidPortal`.
66
+ const base = config.portal.basePath ?? ASTROID_PORTAL_BASE_PATH;
67
+ // Customer credentials, unlike the editor's, are password-based — so these
68
+ // guard credential stuffing and enumeration, not just mail volume.
69
+ rules.push({
70
+ name: "portal-signup",
71
+ method: "POST",
72
+ match: exact(`${base}/sign-up/email`),
73
+ limit: 10,
74
+ windowSec: WINDOW,
75
+ });
76
+ rules.push({
77
+ name: "portal-signin",
78
+ method: "POST",
79
+ match: exact(`${base}/sign-in/email`),
80
+ limit: 15,
81
+ windowSec: WINDOW,
82
+ });
83
+ rules.push({
84
+ name: "portal-reset-request",
85
+ method: "POST",
86
+ match: exact(`${base}/request-password-reset`),
87
+ limit: 5,
88
+ windowSec: WINDOW,
89
+ });
90
+ rules.push({
91
+ name: "portal-reset",
92
+ method: "POST",
93
+ match: exact(`${base}/reset-password`),
94
+ limit: 15,
95
+ windowSec: WINDOW,
96
+ });
97
+ }
98
+ if (config.commerce) {
99
+ // Loosest budget in the set: a real shopper retries, edits, and re-submits a
100
+ // cart, so this is abuse control, not flow control.
101
+ rules.push({
102
+ name: "checkout",
103
+ method: "POST",
104
+ match: exact(ASTROID_CHECKOUT_PATH),
105
+ limit: 20,
106
+ windowSec: WINDOW,
107
+ });
108
+ }
109
+ return rules;
110
+ }
@@ -0,0 +1,3 @@
1
+ export { type AstroidSeoOptions, type AstroidSeoSettings, type PageSeoInput, type ResolvedSeo, resolvePageSeo, } from "./resolve.js";
2
+ export { astroidNoindexPaths, astroidRobotsTxt, astroidSitemapXml, type RobotsOptions, type SitemapEntry, type SitemapOptions, } from "./routes.js";
3
+ export { ARCHETYPE_BUSINESS_TYPE, astroidStructuredData, escapeJsonLd, type JsonLdNode, type StructuredDataInput, } from "./structured-data.js";
@@ -0,0 +1,4 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ export { resolvePageSeo, } from "./resolve.js";
3
+ export { astroidNoindexPaths, astroidRobotsTxt, astroidSitemapXml, } from "./routes.js";
4
+ export { ARCHETYPE_BUSINESS_TYPE, astroidStructuredData, escapeJsonLd, } from "./structured-data.js";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The `site_settings` subset the SEO layer reads. Structurally typed, so a
3
+ * composed settings table (or a plain object in a test) satisfies it without
4
+ * Astroid knowing anything about the rest of the row.
5
+ */
6
+ export interface AstroidSeoSettings {
7
+ siteName?: string | null;
8
+ tagline?: string | null;
9
+ metaDescription?: string | null;
10
+ defaultOgImageUrl?: string | null;
11
+ /** Site-wide kill switch — noindex every page (staging, pre-launch). */
12
+ disableIndexing?: boolean | null;
13
+ }
14
+ /** Per-page SEO: an editor's overrides, or a built-in page's own defaults. */
15
+ export interface PageSeoInput {
16
+ /** Bare title, pre-template. Omit for the site-wide default. */
17
+ title?: string | null;
18
+ description?: string | null;
19
+ /** Absolute URL or a site-root path; resolved against the serving origin. */
20
+ ogImage?: string | null;
21
+ /** `"website"` (default), or `"article"` / `"product"` for a richer card. */
22
+ ogType?: string | null;
23
+ /** Keep this page out of the index (cart, checkout, account, auth). */
24
+ noindex?: boolean | null;
25
+ }
26
+ export interface AstroidSeoOptions {
27
+ /** Canonical URL of the page being rendered — absolute. */
28
+ canonical: string;
29
+ /**
30
+ * Title template, `%s` standing in for the page title. Applied ONLY when the
31
+ * page supplies a title. Default `"%s | <siteName>"`.
32
+ */
33
+ titleTemplate?: string;
34
+ /** `@<handle>` for Twitter/X card attribution. */
35
+ twitterHandle?: string;
36
+ /** OG locale, e.g. `"en_US"`. */
37
+ locale?: string;
38
+ }
39
+ /** Everything a `<head>` needs, fully resolved and absolute. */
40
+ export interface ResolvedSeo {
41
+ /** Final `<title>` — templated when the page supplied one. */
42
+ title: string;
43
+ /** Untemplated page title, for OG/Twitter (which shouldn't carry the site
44
+ * suffix twice — the OG `site_name` already says it). */
45
+ bareTitle: string;
46
+ description?: string;
47
+ canonical: string;
48
+ ogType: string;
49
+ ogImage?: string;
50
+ ogImageAlt?: string;
51
+ siteName?: string;
52
+ locale?: string;
53
+ twitterHandle?: string;
54
+ noindex: boolean;
55
+ }
56
+ /**
57
+ * Resolve a page's SEO against the site settings.
58
+ *
59
+ * ```ts
60
+ * const seo = resolvePageSeo(settings, { title: page.seoTitle, noindex: page.noindex }, {
61
+ * canonical: Astro.url.href,
62
+ * });
63
+ * ```
64
+ *
65
+ * `settings.disableIndexing` wins over everything: it's the site-wide kill
66
+ * switch, so a page that asks to be indexed on a staging deploy still isn't.
67
+ */
68
+ export declare function resolvePageSeo(settings: AstroidSeoSettings, page: PageSeoInput | undefined, options: AstroidSeoOptions): ResolvedSeo;
@@ -0,0 +1,73 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // SEO resolution — settings defaults + per-page overrides, collapsed into the
4
+ // exact set of values a `<head>` needs.
5
+ //
6
+ // Both sites that hand-built this layer converged on the same three-level
7
+ // fallback (page override → the page's computed default → the site-wide
8
+ // setting) and the same non-obvious details: an empty string is *unset*, not a
9
+ // blank tag, so clearing a field in the editor falls back instead of publishing
10
+ // an empty `<meta>`; and the title template applies only when a page supplies
11
+ // its own title, so the home page reads "Acme Coffee" rather than
12
+ // "Acme Coffee | Acme Coffee".
13
+ //
14
+ // Kept as a pure function rather than baked into the component so it can be
15
+ // unit-tested, and so a route that isn't rendering a page (an OG-image endpoint,
16
+ // a feed) can resolve the same values.
17
+ /** Trimmed value, or undefined — an empty/whitespace string counts as unset. */
18
+ const clean = (value) => {
19
+ const trimmed = value?.trim();
20
+ return trimmed ? trimmed : undefined;
21
+ };
22
+ /** Absolute URL for a path or URL, or undefined if it can't be resolved. */
23
+ function absolute(value, base) {
24
+ const raw = clean(value);
25
+ if (!raw)
26
+ return undefined;
27
+ try {
28
+ return new URL(raw, base).toString();
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /**
35
+ * Resolve a page's SEO against the site settings.
36
+ *
37
+ * ```ts
38
+ * const seo = resolvePageSeo(settings, { title: page.seoTitle, noindex: page.noindex }, {
39
+ * canonical: Astro.url.href,
40
+ * });
41
+ * ```
42
+ *
43
+ * `settings.disableIndexing` wins over everything: it's the site-wide kill
44
+ * switch, so a page that asks to be indexed on a staging deploy still isn't.
45
+ */
46
+ export function resolvePageSeo(settings, page = {}, options) {
47
+ const siteName = clean(settings.siteName);
48
+ const pageTitle = clean(page.title);
49
+ const template = options.titleTemplate ?? (siteName ? `%s | ${siteName}` : "%s");
50
+ // No page title → the site-wide title stands alone. Templating it would read
51
+ // "Acme Coffee | Acme Coffee".
52
+ const bareTitle = pageTitle ?? siteName ?? "";
53
+ const title = pageTitle ? template.replace("%s", pageTitle) : bareTitle;
54
+ const description = clean(page.description) ?? clean(settings.metaDescription);
55
+ const ogImage = absolute(page.ogImage, options.canonical) ??
56
+ absolute(settings.defaultOgImageUrl, options.canonical);
57
+ return {
58
+ title,
59
+ bareTitle,
60
+ description,
61
+ canonical: options.canonical,
62
+ ogType: clean(page.ogType) ?? "website",
63
+ ogImage,
64
+ ogImageAlt: ogImage
65
+ ? [siteName, bareTitle].filter(Boolean).join(" — ") || undefined
66
+ : undefined,
67
+ siteName,
68
+ locale: clean(options.locale),
69
+ twitterHandle: clean(options.twitterHandle),
70
+ // The site-wide switch is deliberately not overridable per page.
71
+ noindex: Boolean(settings.disableIndexing) || Boolean(page.noindex),
72
+ };
73
+ }