astroidjs 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/auth/index.d.ts +27 -0
  10. package/dist/auth/index.js +59 -0
  11. package/dist/commerce/adapters.d.ts +60 -0
  12. package/dist/commerce/adapters.js +90 -0
  13. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  14. package/dist/commerce/checkout-scaffold.js +306 -0
  15. package/dist/commerce/checkout.d.ts +72 -0
  16. package/dist/commerce/checkout.js +124 -0
  17. package/dist/commerce/index.d.ts +8 -0
  18. package/dist/commerce/index.js +9 -0
  19. package/dist/commerce/loader.d.ts +71 -0
  20. package/dist/commerce/loader.js +90 -0
  21. package/dist/commerce/mirror.d.ts +69 -0
  22. package/dist/commerce/mirror.js +214 -0
  23. package/dist/commerce/roles.d.ts +38 -0
  24. package/dist/commerce/roles.js +93 -0
  25. package/dist/commerce/secrets.d.ts +74 -0
  26. package/dist/commerce/secrets.js +129 -0
  27. package/dist/commerce/sync.d.ts +86 -0
  28. package/dist/commerce/sync.js +154 -0
  29. package/dist/components/sections.d.ts +577 -0
  30. package/dist/components/sections.js +425 -0
  31. package/dist/config.d.ts +239 -12
  32. package/dist/config.js +49 -1
  33. package/dist/email/index.d.ts +4 -0
  34. package/dist/email/index.js +5 -0
  35. package/dist/email/inquiry.d.ts +33 -0
  36. package/dist/email/inquiry.js +63 -0
  37. package/dist/email/send.d.ts +120 -0
  38. package/dist/email/send.js +196 -0
  39. package/dist/email/templates.d.ts +24 -0
  40. package/dist/email/templates.js +184 -0
  41. package/dist/email/theme.d.ts +24 -0
  42. package/dist/email/theme.js +150 -0
  43. package/dist/errors.d.ts +14 -0
  44. package/dist/errors.js +17 -0
  45. package/dist/index.d.ts +15 -0
  46. package/dist/index.js +15 -0
  47. package/dist/map/index.d.ts +3 -0
  48. package/dist/map/index.js +4 -0
  49. package/dist/map/pmtiles.d.ts +92 -0
  50. package/dist/map/pmtiles.js +130 -0
  51. package/dist/map/scaffold.d.ts +29 -0
  52. package/dist/map/scaffold.js +212 -0
  53. package/dist/map/style.d.ts +58 -0
  54. package/dist/map/style.js +154 -0
  55. package/dist/portal/config.d.ts +26 -0
  56. package/dist/portal/config.js +56 -0
  57. package/dist/portal/guard.d.ts +54 -0
  58. package/dist/portal/guard.js +64 -0
  59. package/dist/portal/index.d.ts +5 -0
  60. package/dist/portal/index.js +6 -0
  61. package/dist/portal/nav.d.ts +26 -0
  62. package/dist/portal/nav.js +35 -0
  63. package/dist/portal/scaffold.d.ts +28 -0
  64. package/dist/portal/scaffold.js +140 -0
  65. package/dist/portal/session.d.ts +36 -0
  66. package/dist/portal/session.js +86 -0
  67. package/dist/portfolio/index.d.ts +1 -0
  68. package/dist/portfolio/index.js +4 -0
  69. package/dist/portfolio/scaffold.d.ts +9 -0
  70. package/dist/portfolio/scaffold.js +93 -0
  71. package/dist/project/actions.d.ts +3 -0
  72. package/dist/project/actions.js +121 -0
  73. package/dist/project/generate.d.ts +15 -0
  74. package/dist/project/generate.js +144 -2
  75. package/dist/project/index.d.ts +2 -0
  76. package/dist/project/index.js +2 -0
  77. package/dist/project/scaffold.d.ts +29 -0
  78. package/dist/project/scaffold.js +166 -0
  79. package/dist/pwa/generate.d.ts +49 -0
  80. package/dist/pwa/generate.js +218 -0
  81. package/dist/pwa/index.d.ts +1 -0
  82. package/dist/pwa/index.js +2 -0
  83. package/dist/queues/consumer.d.ts +29 -0
  84. package/dist/queues/consumer.js +37 -0
  85. package/dist/queues/index.d.ts +4 -0
  86. package/dist/queues/index.js +5 -0
  87. package/dist/queues/messages.d.ts +60 -0
  88. package/dist/queues/messages.js +71 -0
  89. package/dist/queues/scaffold.d.ts +44 -0
  90. package/dist/queues/scaffold.js +204 -0
  91. package/dist/queues/webhook.d.ts +60 -0
  92. package/dist/queues/webhook.js +81 -0
  93. package/dist/realtime/index.d.ts +1 -0
  94. package/dist/realtime/index.js +4 -0
  95. package/dist/realtime/scaffold.d.ts +30 -0
  96. package/dist/realtime/scaffold.js +159 -0
  97. package/dist/schema/collections.d.ts +41 -7
  98. package/dist/schema/collections.js +110 -12
  99. package/dist/schema/framework.js +5 -0
  100. package/dist/schema/generate.js +17 -1
  101. package/dist/secrets.d.ts +54 -0
  102. package/dist/secrets.js +80 -0
  103. package/dist/security/index.d.ts +1 -0
  104. package/dist/security/index.js +2 -0
  105. package/dist/security/rate-rules.d.ts +21 -0
  106. package/dist/security/rate-rules.js +110 -0
  107. package/dist/seo/index.d.ts +3 -0
  108. package/dist/seo/index.js +4 -0
  109. package/dist/seo/resolve.d.ts +68 -0
  110. package/dist/seo/resolve.js +73 -0
  111. package/dist/seo/routes.d.ts +44 -0
  112. package/dist/seo/routes.js +104 -0
  113. package/dist/seo/structured-data.d.ts +51 -0
  114. package/dist/seo/structured-data.js +105 -0
  115. package/dist/status.d.ts +51 -0
  116. package/dist/status.js +113 -0
  117. package/dist/worker/generate.d.ts +18 -10
  118. package/dist/worker/generate.js +353 -42
  119. package/dist/worker/routes.d.ts +1 -1
  120. package/dist/worker/routes.js +42 -0
  121. package/dist/workflow/advance.d.ts +102 -0
  122. package/dist/workflow/advance.js +145 -0
  123. package/dist/workflow/config.d.ts +60 -0
  124. package/dist/workflow/config.js +73 -0
  125. package/dist/workflow/generate.d.ts +22 -0
  126. package/dist/workflow/generate.js +138 -0
  127. package/dist/workflow/index.d.ts +3 -0
  128. package/dist/workflow/index.js +4 -0
  129. package/package.json +21 -4
  130. package/src/components/Editable.astro +33 -9
  131. package/src/components/JustifiedGallery.astro +254 -0
  132. package/src/components/MediaSlot.astro +178 -0
  133. package/src/components/PortalShell.astro +80 -0
  134. package/src/components/RegisterSW.astro +45 -0
  135. package/src/components/Section.astro +101 -35
  136. package/src/components/Sections.astro +64 -0
  137. package/src/components/Seo.astro +57 -0
  138. package/src/components/StageBar.astro +137 -0
  139. package/src/components/StructuredData.astro +33 -0
  140. package/src/components/justify.ts +170 -0
  141. package/src/components/media-meta.ts +174 -0
  142. package/src/components/sections/AboutIntro.astro +46 -0
  143. package/src/components/sections/Banner.astro +31 -0
  144. package/src/components/sections/Contact.astro +22 -9
  145. package/src/components/sections/Cta.astro +33 -10
  146. package/src/components/sections/Faq.astro +50 -0
  147. package/src/components/sections/FeatureGrid.astro +40 -11
  148. package/src/components/sections/Gallery.astro +46 -0
  149. package/src/components/sections/Hero.astro +40 -12
  150. package/src/components/sections/LocationHours.astro +59 -0
  151. package/src/components/sections/Media.astro +44 -0
  152. package/src/components/sections/PricingTiers.astro +79 -0
  153. package/src/components/sections/ProductGrid.astro +73 -0
  154. package/src/components/sections/SplitImage.astro +61 -0
  155. package/src/components/sections/Steps.astro +58 -0
  156. package/src/components/sections/Testimonial.astro +51 -0
  157. package/src/components/sections.ts +452 -67
@@ -0,0 +1,196 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Best-effort delivery.
4
+ //
5
+ // Transactional mail in this stack is always store-and-forward: the inquiry row
6
+ // is already in D1, the account already exists. Mail is the *notification* of
7
+ // something that happened, so a mail failure must never fail the request that
8
+ // caused it — and never throw into a `waitUntil` where it becomes an unhandled
9
+ // rejection. Every path here resolves.
10
+ //
11
+ // The second job is the dev story. There is no EMAIL binding under `wrangler
12
+ // dev`, and the single most common local task is "click the magic link" — so an
13
+ // unconfigured mailer LOGS the message instead of silently dropping it, and it
14
+ // logs the plaintext body, which is where the link is. That is the whole reason
15
+ // every template renders a text alternative.
16
+ //
17
+ // This is the email module's half of the dormant-until-provisioned convention:
18
+ // no binding means dormant and loudly simulated, not broken.
19
+ import { sendEmail } from "louise-toolkit/email";
20
+ import { resolveModuleSecrets } from "../secrets.js";
21
+ /**
22
+ * The secrets the mailer needs. Just the envelope sender: the EMAIL binding is a
23
+ * binding, not a secret, so it's gated by presence rather than by value.
24
+ *
25
+ * Named here so `astroid doctor` and the scaffold's `.dev.vars` seed read the
26
+ * same list the runtime gate does.
27
+ */
28
+ export const EMAIL_SECRET_NAMES = ["MAIL_FROM"];
29
+ /**
30
+ * Resolve the email module's dormancy.
31
+ *
32
+ * Both halves are required, and for the same reason: a binding with no sender
33
+ * address can't build an envelope, and a sender address with no binding has
34
+ * nothing to send through. Either one missing means log-and-continue, which
35
+ * under `wrangler dev` (no EMAIL binding at all) is the normal case — and the
36
+ * reason the magic-link flow is still workable locally.
37
+ */
38
+ export async function resolveMailerStatus(env) {
39
+ const secrets = await resolveModuleSecrets({ MAIL_FROM: env.MAIL_FROM });
40
+ const hasBinding = Boolean(env.EMAIL);
41
+ return {
42
+ configured: secrets.configured && hasBinding,
43
+ values: secrets.values,
44
+ missing: hasBinding ? [...secrets.missing] : [...secrets.missing, "EMAIL"],
45
+ hasBinding,
46
+ };
47
+ }
48
+ /**
49
+ * Build `MailerOptions` from an env, with dormancy already decided.
50
+ *
51
+ * The point of routing through {@link resolveMailerStatus} rather than checking
52
+ * `!env.EMAIL` inline is that the placeholder sentinel counts: a scaffold seeds
53
+ * `MAIL_FROM=DUMMY_REPLACE_ME`, and handing that to the Email API as an
54
+ * envelope sender is exactly the "called upstream with a dummy credential"
55
+ * failure the convention exists to prevent.
56
+ */
57
+ export async function resolveMailer(env, overrides = {}) {
58
+ const status = await resolveMailerStatus(env);
59
+ return {
60
+ ...overrides,
61
+ binding: env.EMAIL ?? null,
62
+ from: status.values.MAIL_FROM ?? "noreply@localhost",
63
+ logOnly: overrides.logOnly || !status.configured,
64
+ status,
65
+ };
66
+ }
67
+ /**
68
+ * The console rendering of an unsent message.
69
+ *
70
+ * The body is the whole point in dev — that's where a sign-in link actually is,
71
+ * and printing it is what lets you sign in with no mail provider configured.
72
+ *
73
+ * It is also a credential. `logOnly` turns on whenever `MAIL_FROM` is unset, and
74
+ * that can happen in PRODUCTION — a secret that didn't get set, or a Secrets
75
+ * Store read that failed. The body then went to `console.info`, which means
76
+ * `wrangler tail` and every Logpush sink, carrying live single-use magic links
77
+ * and password-reset URLs. Anyone with read access to observability could take
78
+ * over an editor or portal account.
79
+ *
80
+ * So the body is printed only when we can see we're in development. Everywhere
81
+ * else the log still records that a message went unsent, and why, but not the
82
+ * credential inside it.
83
+ */
84
+ function describe(mail, reason, includeBody) {
85
+ const head = [
86
+ `[astroid:email] ${reason} — not sent`,
87
+ ` to: ${mail.to}`,
88
+ ` subject: ${mail.content.subject}`,
89
+ ];
90
+ if (!includeBody) {
91
+ return [
92
+ ...head,
93
+ " (body withheld — it can contain a single-use sign-in or reset link, and this",
94
+ " does not look like a development environment. Set MAIL_FROM and the EMAIL",
95
+ " binding to deliver it, or pass `devLog: true` if this really is local.)",
96
+ ].join("\n");
97
+ }
98
+ return [...head, " ---", ...mail.content.text.split("\n").map((line) => ` ${line}`), " ---"].join("\n");
99
+ }
100
+ /**
101
+ * Best-effort "are we in development?".
102
+ *
103
+ * Deliberately conservative — it decides whether a credential is printed, so an
104
+ * unknown environment must read as production. Workers has no `NODE_ENV`, so we
105
+ * look at the signals that do exist and let a caller override explicitly.
106
+ */
107
+ function looksLikeDev() {
108
+ // Vite/Astro define this at build time; `import.meta.env` is absent in a plain
109
+ // Worker, hence the guarded read.
110
+ const viteDev = import.meta.env?.DEV;
111
+ if (typeof viteDev === "boolean")
112
+ return viteDev;
113
+ const nodeEnv = globalThis.process
114
+ ?.env?.NODE_ENV;
115
+ if (typeof nodeEnv === "string")
116
+ return nodeEnv !== "production";
117
+ return false;
118
+ }
119
+ /**
120
+ * Send a batch of transactional messages, best effort.
121
+ *
122
+ * Delivery runs concurrently and independently: an inquiry sends a notification
123
+ * to the owner and a confirmation to the visitor, and the owner's copy must
124
+ * still arrive when the visitor typo'd their address. Callers get a result per
125
+ * message and decide whether to care.
126
+ *
127
+ * ```ts
128
+ * const results = await sendTransactional(
129
+ * { binding: env.EMAIL, from: env.MAIL_FROM },
130
+ * [
131
+ * { to: owner, content: inquiryNotificationEmail(theme, i), replyTo: i.email },
132
+ * { to: i.email, content: inquiryConfirmationEmail(theme, i) },
133
+ * ],
134
+ * );
135
+ * ```
136
+ */
137
+ export async function sendTransactional(options, mails) {
138
+ const log = options.log ?? ((message) => console.info(message));
139
+ const dormant = !options.binding || options.logOnly;
140
+ if (dormant) {
141
+ const reason = options.binding ? "log-only" : "not-configured";
142
+ const includeBody = options.devLog ?? looksLikeDev();
143
+ for (const mail of mails)
144
+ log(describe(mail, reason, includeBody));
145
+ return mails.map((mail) => ({
146
+ to: mail.to,
147
+ subject: mail.content.subject,
148
+ delivered: false,
149
+ reason,
150
+ }));
151
+ }
152
+ const binding = options.binding;
153
+ const settled = await Promise.allSettled(mails.map((mail) => sendEmail(binding, {
154
+ from: options.from,
155
+ to: mail.to,
156
+ subject: mail.content.subject,
157
+ html: mail.content.html,
158
+ text: mail.content.text,
159
+ ...(mail.replyTo ? { replyTo: mail.replyTo } : {}),
160
+ })));
161
+ return settled.map((result, i) => {
162
+ const mail = mails[i];
163
+ if (result.status === "fulfilled") {
164
+ return {
165
+ to: mail.to,
166
+ subject: mail.content.subject,
167
+ delivered: true,
168
+ messageId: result.value.messageId,
169
+ };
170
+ }
171
+ // A genuine delivery failure is LOGGED, not just returned.
172
+ //
173
+ // The result array was the only record of it, and the one caller that
174
+ // matters — the generated inquiry handler — discards it by design (the row
175
+ // is already durable, and the visitor must not see a 500 because the owner's
176
+ // notification bounced). So a dead Email Sending domain or an exhausted
177
+ // quota produced silence everywhere: a success page for the visitor, nothing
178
+ // for the owner, and nothing in `wrangler tail`. Weeks later someone asks
179
+ // why the contact form stopped working.
180
+ //
181
+ // Logged here rather than left to callers because this is the only place
182
+ // that knows a send was attempted and failed; `log` is already injectable
183
+ // for tests and for routing somewhere other than console.
184
+ const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
185
+ log(`[astroid:email] delivery FAILED to ${mail.to} (${mail.content.subject}): ${reason}`);
186
+ return { to: mail.to, subject: mail.content.subject, delivered: false, reason };
187
+ });
188
+ }
189
+ /**
190
+ * Bind a mailer's options once so call sites read as `mailer([...])`. Useful
191
+ * where the binding and sender are resolved per request but the sends are
192
+ * scattered (an inquiry hook, an auth callback).
193
+ */
194
+ export function createMailer(options) {
195
+ return (mails) => sendTransactional(options, mails);
196
+ }
@@ -0,0 +1,24 @@
1
+ import { type MailContent, type MailTheme } from "louise-toolkit/email";
2
+ export type { MailContent, MailTheme };
3
+ /** Editor sign-in magic link. */
4
+ export declare function magicLinkEmail(theme: MailTheme, params: {
5
+ url: string;
6
+ toEmail: string;
7
+ }): MailContent;
8
+ /** Password-reset link (the portal's credential flow). */
9
+ export declare function passwordResetEmail(theme: MailTheme, params: {
10
+ url: string;
11
+ toEmail: string;
12
+ }): MailContent;
13
+ /** The fields an inquiry contributes to both halves of the pair. */
14
+ export interface InquiryDetails {
15
+ name: string;
16
+ email: string;
17
+ /** Subject line the visitor picked, if the form offers one. */
18
+ regarding?: string;
19
+ message: string;
20
+ }
21
+ /** Owner-facing notification for a new contact-form submission. */
22
+ export declare function inquiryNotificationEmail(theme: MailTheme, i: InquiryDetails): MailContent;
23
+ /** Confirmation back to whoever submitted the contact form. */
24
+ export declare function inquiryConfirmationEmail(theme: MailTheme, i: InquiryDetails): MailContent;
@@ -0,0 +1,184 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The standard transactional set: sign-in link, password reset, and the
4
+ // inquiry pair (notify the owner, confirm to the sender).
5
+ //
6
+ // All three consuming sites wrote these four, with the same structure and
7
+ // near-identical copy — only the brand name differed, which is exactly what
8
+ // makes them first-party rather than site-side. The brand-agnostic *frame*
9
+ // (card, colour band, CTA button, paste-this-link fallback) already lives in
10
+ // `louise-toolkit/email`; this file owns the wording and the layout inside it.
11
+ //
12
+ // Every template returns HTML **and** plaintext from one definition. The text
13
+ // alternative is not decoration: a message with no text/plain part scores worse
14
+ // with spam filters, and for a sign-in link the plaintext body is what a
15
+ // terminal-based or accessibility client actually shows.
16
+ //
17
+ // Escaping is the other constant: every value that reaches these functions came
18
+ // from a form or a database, and it lands inside an HTML document.
19
+ import { escapeHtml, escapeMultiline, mailButton, mailFallbackLink, renderEmailShell, subjectSafe, } from "louise-toolkit/email";
20
+ /** Body paragraph in the theme's sans stack. */
21
+ const p = (theme, html, opts = {}) => `<p style="font-family:${theme.fonts.sans};font-size:${opts.muted ? "15px" : "16px"};line-height:1.6;color:${opts.muted ? theme.palette.inkMute : theme.palette.inkSoft};margin:${opts.margin ?? "0 0 12px"};">${html}</p>`;
22
+ /** Small uppercase mono label. */
23
+ const label = (theme, text, margin = "0 0 10px") => `<p style="font-family:${theme.fonts.mono};font-size:10px;letter-spacing:0.1em;text-transform:uppercase;color:${theme.palette.inkMute};margin:${margin};">${text}</p>`;
24
+ /** A quoted block for user-authored text (a message body). */
25
+ const quote = (theme, text) => `<div style="font-family:${theme.fonts.sans};font-size:15px;line-height:1.65;color:${theme.palette.ink};padding:16px 18px;background:${theme.palette.bgSoft};border:1px solid ${theme.palette.rule};border-radius:6px;">${escapeMultiline(text)}</div>`;
26
+ /**
27
+ * A one-time link email — the shared shape behind sign-in and password reset.
28
+ * Both are "here is a URL, it expires, ignore this if it wasn't you", and the
29
+ * only differences are the words.
30
+ */
31
+ function linkEmail(theme, opts) {
32
+ const bodyHtml = [
33
+ p(theme, opts.lead),
34
+ p(theme, `This link expires in <strong style="color:${theme.palette.ink};font-weight:600;">${opts.expiry}</strong> and can only be used once.`, { muted: true, margin: "0 0 30px" }),
35
+ mailButton(theme, { href: opts.url, label: `${opts.buttonLabel} &rarr;` }),
36
+ mailFallbackLink(theme, opts.url),
37
+ p(theme, opts.disclaimer, { muted: true, margin: "26px 0 0" }),
38
+ ].join("\n");
39
+ return {
40
+ subject: opts.subject,
41
+ html: renderEmailShell(theme, {
42
+ title: opts.title,
43
+ preheader: opts.preheader,
44
+ eyebrow: opts.eyebrow,
45
+ headline: opts.headline,
46
+ bodyHtml,
47
+ footerNote: `Sent to ${escapeHtml(opts.toEmail)} &middot; ${opts.footerNote}`,
48
+ }),
49
+ text: [
50
+ opts.headline.replace(/&[a-z]+;/g, "'"),
51
+ "",
52
+ opts.textLead,
53
+ `It expires in ${opts.expiry} and can only be used once.`,
54
+ "",
55
+ opts.url,
56
+ "",
57
+ opts.disclaimer.replace(/&[a-z]+;/g, "'").replace(/<[^>]+>/g, ""),
58
+ ].join("\n"),
59
+ };
60
+ }
61
+ /** Editor sign-in magic link. */
62
+ export function magicLinkEmail(theme, params) {
63
+ const brand = theme.brand.name;
64
+ return linkEmail(theme, {
65
+ subject: `Your sign-in link — ${brand}`,
66
+ title: "Your sign-in link",
67
+ preheader: "Your one-time sign-in link — expires in 15 minutes.",
68
+ eyebrow: "Sign in",
69
+ headline: "Here&rsquo;s your magic link.",
70
+ lead: `Use the button below to sign in to ${escapeHtml(brand)}. No password needed.`,
71
+ expiry: "15 minutes",
72
+ buttonLabel: "Sign in",
73
+ url: params.url,
74
+ toEmail: params.toEmail,
75
+ disclaimer: "If you didn&rsquo;t request this, you can safely ignore this email.",
76
+ footerNote: "Automated sign-in message.",
77
+ textLead: `Use this link to sign in to ${brand} — no password needed.`,
78
+ });
79
+ }
80
+ /** Password-reset link (the portal's credential flow). */
81
+ export function passwordResetEmail(theme, params) {
82
+ const brand = theme.brand.name;
83
+ return linkEmail(theme, {
84
+ subject: `Reset your password — ${brand}`,
85
+ title: "Reset your password",
86
+ preheader: "Reset your password — this link expires in 1 hour.",
87
+ eyebrow: "Password reset",
88
+ headline: "Reset your password.",
89
+ lead: `We got a request to reset the password on your ${escapeHtml(brand)} account. Use the button below to choose a new one.`,
90
+ expiry: "1 hour",
91
+ buttonLabel: "Reset your password",
92
+ url: params.url,
93
+ toEmail: params.toEmail,
94
+ disclaimer: "If you didn&rsquo;t request this, you can safely ignore this email &mdash; your password won&rsquo;t change.",
95
+ footerNote: "Automated account message.",
96
+ textLead: `We got a request to reset the password on your ${brand} account. Use this link to choose a new one.`,
97
+ });
98
+ }
99
+ /** Owner-facing notification for a new contact-form submission. */
100
+ export function inquiryNotificationEmail(theme, i) {
101
+ const { palette: c, fonts: f } = theme;
102
+ const name = i.name.trim() || "Someone";
103
+ const safeEmail = escapeHtml(i.email);
104
+ const row = (k, v) => `<tr>
105
+ <td style="padding:10px 0;border-bottom:1px solid ${c.ruleSoft};font-family:${f.mono};font-size:10px;letter-spacing:0.1em;text-transform:uppercase;color:${c.inkMute};white-space:nowrap;vertical-align:top;width:96px;">${k}</td>
106
+ <td style="padding:10px 0 10px 16px;border-bottom:1px solid ${c.ruleSoft};font-family:${f.sans};font-size:15px;line-height:1.55;color:${c.ink};">${v}</td>
107
+ </tr>`;
108
+ const bodyHtml = [
109
+ p(theme, "A new message just came in through the contact form.", { margin: "0 0 24px" }),
110
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 26px;">
111
+ ${row("From", escapeHtml(name))}
112
+ ${row("Email", `<a href="mailto:${safeEmail}" style="color:${c.accent};text-decoration:none;">${safeEmail}</a>`)}
113
+ ${i.regarding?.trim() ? row("Regarding", escapeHtml(i.regarding.trim())) : ""}
114
+ </table>`,
115
+ label(theme, "Message"),
116
+ quote(theme, i.message),
117
+ p(theme, `Just reply to this email to respond to ${escapeHtml(name)} directly.`, {
118
+ muted: true,
119
+ margin: "24px 0 0",
120
+ }),
121
+ ].join("\n");
122
+ return {
123
+ // `subjectSafe` collapses newlines: a name field is visitor-supplied, and a
124
+ // newline in a Subject header is a header-injection primitive.
125
+ subject: subjectSafe(`New inquiry from ${name}`),
126
+ html: renderEmailShell(theme, {
127
+ title: "New inquiry",
128
+ preheader: escapeHtml(`${name} — ${i.regarding?.trim() || "New inquiry"}`),
129
+ eyebrow: "New inquiry",
130
+ headline: "You&rsquo;ve got a new message.",
131
+ bodyHtml,
132
+ footerNote: `Reply goes to ${safeEmail} &middot; Automated notification.`,
133
+ }),
134
+ text: [
135
+ "New inquiry",
136
+ "",
137
+ `From: ${name}`,
138
+ `Email: ${i.email}`,
139
+ ...(i.regarding?.trim() ? [`Regarding: ${i.regarding.trim()}`] : []),
140
+ "",
141
+ "Message:",
142
+ i.message,
143
+ ].join("\n"),
144
+ };
145
+ }
146
+ /** Confirmation back to whoever submitted the contact form. */
147
+ export function inquiryConfirmationEmail(theme, i) {
148
+ const brand = theme.brand.name;
149
+ // Only the given name — "Hi Jane Smith" reads like a form letter, which is
150
+ // precisely what this is trying not to.
151
+ const first = i.name.trim().split(/\s+/)[0] || "there";
152
+ const bodyHtml = [
153
+ p(theme, `Hi ${escapeHtml(first)} &mdash; thanks for reaching out. Your message has landed, and we answer personally, usually within a business day or two.`),
154
+ i.regarding?.trim()
155
+ ? p(theme, `Regarding: <strong style="color:${theme.palette.ink};font-weight:600;">${escapeHtml(i.regarding.trim())}</strong>`, { muted: true, margin: "0 0 24px" })
156
+ : "",
157
+ label(theme, "Your message"),
158
+ quote(theme, i.message),
159
+ p(theme, "No need to reply &mdash; this is just a confirmation that yours came through.", {
160
+ muted: true,
161
+ margin: "24px 0 0",
162
+ }),
163
+ ].join("\n");
164
+ return {
165
+ subject: `Thanks for your message — ${brand}`,
166
+ html: renderEmailShell(theme, {
167
+ title: "We got your message",
168
+ preheader: "Thanks for reaching out — we answer personally, usually within a day or two.",
169
+ eyebrow: escapeHtml(brand),
170
+ headline: "Thanks &mdash; we&rsquo;ve got your message.",
171
+ bodyHtml,
172
+ footerNote: "Automated confirmation &middot; No reply needed.",
173
+ }),
174
+ text: [
175
+ `Hi ${first} — thanks for reaching out.`,
176
+ "",
177
+ "Your message has landed, and we answer personally, usually within a business day or two.",
178
+ ...(i.regarding?.trim() ? ["", `Regarding: ${i.regarding.trim()}`] : []),
179
+ "",
180
+ "Your message:",
181
+ i.message,
182
+ ].join("\n"),
183
+ };
184
+ }
@@ -0,0 +1,24 @@
1
+ import type { MailPalette, MailTheme } from "louise-toolkit/email";
2
+ import type { AstroidConfig } from "../config.js";
3
+ /** Deep-mergeable overrides for the parts a site wants to own. */
4
+ export interface MailThemeOverrides {
5
+ palette?: Partial<MailPalette>;
6
+ band?: string[];
7
+ fonts?: Partial<MailTheme["fonts"]>;
8
+ brand?: Partial<MailTheme["brand"]>;
9
+ radius?: number;
10
+ bandHeight?: number;
11
+ buttonShape?: MailTheme["buttonShape"];
12
+ }
13
+ /**
14
+ * Build the project's transactional-mail theme from its `defineAstroid` config.
15
+ *
16
+ * ```ts
17
+ * const theme = astroidMailTheme(config);
18
+ * const mail = magicLinkEmail(theme, { url, toEmail });
19
+ * ```
20
+ *
21
+ * An invalid or missing brand colour falls back to the ink neutral rather than
22
+ * throwing — a malformed hex in settings should not take out password reset.
23
+ */
24
+ export declare function astroidMailTheme(config: AstroidConfig, overrides?: MailThemeOverrides): MailTheme;
@@ -0,0 +1,150 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Deriving a `MailTheme` from the project's brand.
4
+ //
5
+ // The toolkit's email shell takes a fully-specified theme — ten palette slots, a
6
+ // colour band, three font stacks. Every site hand-picked all of it, which is
7
+ // exactly the kind of work a config should absorb: an Astroid project already
8
+ // declares `theme.colors`, and that is enough to produce a mail theme that looks
9
+ // deliberate rather than defaulted.
10
+ //
11
+ // Two decisions here are load-bearing:
12
+ //
13
+ // 1. **Neutrals are fixed, brand colours are derived.** Page background, ink,
14
+ // rules — those are typography choices, not brand ones, and a site that
15
+ // wants different ones passes an override. What varies per brand is the
16
+ // accent and the colour band, and both come from `theme.colors`.
17
+ // 2. **The accent is contrast-corrected.** A pale brand colour used verbatim
18
+ // as the eyebrow + link colour is unreadable on a near-white card. Rather
19
+ // than hope nobody picks yellow, the accent is darkened until it clears
20
+ // WCAG AA against the card background.
21
+ function hexToRgb(hex) {
22
+ const raw = hex.trim().replace(/^#/, "");
23
+ const full = raw.length === 3 ? [...raw].map((c) => c + c).join("") : raw;
24
+ if (!/^[0-9a-fA-F]{6}$/.test(full))
25
+ return null;
26
+ return [
27
+ Number.parseInt(full.slice(0, 2), 16),
28
+ Number.parseInt(full.slice(2, 4), 16),
29
+ Number.parseInt(full.slice(4, 6), 16),
30
+ ];
31
+ }
32
+ const rgbToHex = ([r, g, b]) => `#${[r, g, b]
33
+ .map((v) => Math.round(Math.max(0, Math.min(255, v)))
34
+ .toString(16)
35
+ .padStart(2, "0"))
36
+ .join("")}`;
37
+ /** Linear blend from `a` to `b`; `t` of 0 is all `a`, 1 is all `b`. */
38
+ const mix = (a, b, t) => [
39
+ a[0] + (b[0] - a[0]) * t,
40
+ a[1] + (b[1] - a[1]) * t,
41
+ a[2] + (b[2] - a[2]) * t,
42
+ ];
43
+ const WHITE = [255, 255, 255];
44
+ const BLACK = [0, 0, 0];
45
+ const tint = (c, t) => mix(c, WHITE, t);
46
+ const shade = (c, t) => mix(c, BLACK, t);
47
+ /** WCAG relative luminance. */
48
+ function luminance([r, g, b]) {
49
+ const channel = (v) => {
50
+ const s = v / 255;
51
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
52
+ };
53
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
54
+ }
55
+ /** WCAG contrast ratio between two colours (1–21). */
56
+ function contrast(a, b) {
57
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
58
+ return (hi + 0.05) / (lo + 0.05);
59
+ }
60
+ /**
61
+ * Darken `color` until it clears `minRatio` against `bg`. A brand colour is
62
+ * chosen to look good on a website, and plenty of good ones (yellows, pale
63
+ * teals) are illegible as 11px uppercase text on a near-white email card — mail
64
+ * clients offer no dark-mode escape hatch, so this is corrected up front.
65
+ */
66
+ function readableOn(color, bg, minRatio = 4.5) {
67
+ let out = color;
68
+ // 20 steps of 5% each bottoms out at black, which always passes on a light bg.
69
+ for (let i = 0; i < 20 && contrast(out, bg) < minRatio; i++)
70
+ out = shade(out, 0.05);
71
+ return out;
72
+ }
73
+ /**
74
+ * The five-cell colour band across the top of the card. Always five cells,
75
+ * whatever the brand supplies, so the masthead reads as a designed element
76
+ * rather than "however many colours happened to be configured":
77
+ *
78
+ * one colour → a light-to-dark ramp through it
79
+ * two colours → a ramp bridging the two
80
+ * three → the three, with a light lead and a dark tail
81
+ */
82
+ function buildBand(colors) {
83
+ const [a, b, c] = colors;
84
+ if (!b)
85
+ return [tint(a, 0.4), tint(a, 0.18), a, shade(a, 0.18), shade(a, 0.34)].map(rgbToHex);
86
+ if (!c)
87
+ return [tint(a, 0.35), a, mix(a, b, 0.5), b, shade(b, 0.25)].map(rgbToHex);
88
+ return [tint(a, 0.3), a, b, c, shade(c, 0.25)].map(rgbToHex);
89
+ }
90
+ /** Warm-neutral defaults. Brand-agnostic typography choices, not brand ones. */
91
+ const NEUTRALS = {
92
+ pageBg: "#EDEBE4",
93
+ bg: "#FFFDF7",
94
+ bgSoft: "#FAF7EF",
95
+ ink: "#1A1A1A",
96
+ inkSoft: "#4A4A4A",
97
+ inkMute: "#6D6D6D",
98
+ rule: "#E1DED4",
99
+ ruleSoft: "#EDEBE4",
100
+ onDark: "#FAF7EF",
101
+ };
102
+ /** Web-safe stacks. Email clients can't be relied on to load a webfont, so the
103
+ * project's `theme.font` leads and a real fallback follows. */
104
+ function buildFonts(font) {
105
+ const lead = font?.trim() ? `'${font.trim()}', ` : "";
106
+ return {
107
+ serif: `${lead}Georgia, 'Times New Roman', serif`,
108
+ sans: `${lead}-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif`,
109
+ mono: "ui-monospace, Menlo, Consolas, 'Courier New', monospace",
110
+ };
111
+ }
112
+ /**
113
+ * Build the project's transactional-mail theme from its `defineAstroid` config.
114
+ *
115
+ * ```ts
116
+ * const theme = astroidMailTheme(config);
117
+ * const mail = magicLinkEmail(theme, { url, toEmail });
118
+ * ```
119
+ *
120
+ * An invalid or missing brand colour falls back to the ink neutral rather than
121
+ * throwing — a malformed hex in settings should not take out password reset.
122
+ */
123
+ export function astroidMailTheme(config, overrides = {}) {
124
+ const cardBg = hexToRgb(NEUTRALS.bg) ?? WHITE;
125
+ const brandColors = [
126
+ config.theme.colors.brand,
127
+ config.theme.colors.secondary,
128
+ config.theme.colors.tertiary,
129
+ ]
130
+ .map((c) => (c ? hexToRgb(c) : null))
131
+ .filter((c) => c !== null);
132
+ const base = brandColors[0] ?? hexToRgb(NEUTRALS.ink);
133
+ return {
134
+ palette: {
135
+ ...NEUTRALS,
136
+ accent: rgbToHex(readableOn(base, cardBg)),
137
+ ...overrides.palette,
138
+ },
139
+ band: overrides.band ?? buildBand(brandColors.length ? brandColors : [base]),
140
+ fonts: { ...buildFonts(config.theme.font), ...overrides.fonts },
141
+ brand: {
142
+ name: config.theme.name,
143
+ footerLead: config.theme.name,
144
+ ...overrides.brand,
145
+ },
146
+ radius: overrides.radius ?? 8,
147
+ bandHeight: overrides.bandHeight ?? 110,
148
+ buttonShape: overrides.buttonShape ?? "pill",
149
+ };
150
+ }
package/dist/errors.d.ts CHANGED
@@ -3,3 +3,17 @@
3
3
  export declare class AstroidConfigError extends Error {
4
4
  constructor(message: string);
5
5
  }
6
+ /**
7
+ * Thrown when an Astroid runtime helper is called with arguments that would
8
+ * produce a silently wrong result.
9
+ *
10
+ * Distinct from {@link AstroidConfigError}, which is a build-time contract: this
11
+ * one fires on a live request, so it must be something a handler can catch and
12
+ * turn into a 5xx rather than something that reads like a misconfigured project.
13
+ * Reserved for cases where carrying on would be worse than failing — a checkout
14
+ * whose idempotency key collides with another customer's, say, where the damage
15
+ * (a buyer who is never charged) is invisible at the call site.
16
+ */
17
+ export declare class AstroidUsageError extends Error {
18
+ constructor(message: string);
19
+ }
package/dist/errors.js CHANGED
@@ -11,3 +11,20 @@ export class AstroidConfigError extends Error {
11
11
  this.name = "AstroidConfigError";
12
12
  }
13
13
  }
14
+ /**
15
+ * Thrown when an Astroid runtime helper is called with arguments that would
16
+ * produce a silently wrong result.
17
+ *
18
+ * Distinct from {@link AstroidConfigError}, which is a build-time contract: this
19
+ * one fires on a live request, so it must be something a handler can catch and
20
+ * turn into a 5xx rather than something that reads like a misconfigured project.
21
+ * Reserved for cases where carrying on would be worse than failing — a checkout
22
+ * whose idempotency key collides with another customer's, say, where the damage
23
+ * (a buyer who is never charged) is invisible at the call site.
24
+ */
25
+ export class AstroidUsageError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "AstroidUsageError";
29
+ }
30
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,20 @@
1
+ export * from "./analytics/index.js";
2
+ export * from "./auth/index.js";
3
+ export * from "./commerce/index.js";
1
4
  export * from "./config.js";
5
+ export * from "./email/index.js";
2
6
  export * from "./errors.js";
7
+ export * from "./secrets.js";
8
+ export * from "./map/index.js";
9
+ export * from "./portal/index.js";
10
+ export * from "./portfolio/index.js";
3
11
  export * from "./project/index.js";
12
+ export * from "./pwa/index.js";
13
+ export * from "./realtime/index.js";
14
+ export * from "./queues/index.js";
4
15
  export * from "./schema/index.js";
16
+ export * from "./security/index.js";
17
+ export * from "./seo/index.js";
18
+ export * from "./status.js";
5
19
  export * from "./worker/index.js";
20
+ export * from "./workflow/index.js";
package/dist/index.js CHANGED
@@ -3,8 +3,23 @@
3
3
  // astroidjs — the opinionated meta-framework over Louise Toolkit + Astro.
4
4
  // Public entry. The configuration surface (`defineAstroid`) is the first
5
5
  // inhabitant; the generator, theme system, and section library follow.
6
+ export * from "./analytics/index.js";
7
+ export * from "./auth/index.js";
8
+ export * from "./commerce/index.js";
6
9
  export * from "./config.js";
10
+ export * from "./email/index.js";
7
11
  export * from "./errors.js";
12
+ export * from "./secrets.js";
13
+ export * from "./map/index.js";
14
+ export * from "./portal/index.js";
15
+ export * from "./portfolio/index.js";
8
16
  export * from "./project/index.js";
17
+ export * from "./pwa/index.js";
18
+ export * from "./realtime/index.js";
19
+ export * from "./queues/index.js";
9
20
  export * from "./schema/index.js";
21
+ export * from "./security/index.js";
22
+ export * from "./seo/index.js";
23
+ export * from "./status.js";
10
24
  export * from "./worker/index.js";
25
+ export * from "./workflow/index.js";
@@ -0,0 +1,3 @@
1
+ export { ASTROID_MAP_DEPENDENCIES, ASTROID_PMTILES_KEY, ASTROID_PMTILES_PATH, generateMapEmbedComponent, generateMapTileRoute, usesMap, } from "./scaffold.js";
2
+ export { type ParsedRange, parseRangeHeader, type PmtilesHandlerOptions, type RangeObject, type RangeReader, type RangeSpec, servePmtiles, } from "./pmtiles.js";
3
+ export { astroidMapStyle, type MapColors, type MapStyle, type MapStyleOptions } from "./style.js";