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.
- package/README.md +240 -5
- package/bin/astroid.mjs +185 -9
- package/dist/analytics/index.d.ts +37 -0
- package/dist/analytics/index.js +108 -0
- package/dist/astro/csp.d.ts +64 -0
- package/dist/astro/csp.js +173 -0
- package/dist/astro/index.d.ts +1 -0
- package/dist/astro/index.js +7 -0
- package/dist/commerce/adapters.d.ts +60 -0
- package/dist/commerce/adapters.js +90 -0
- package/dist/commerce/checkout-scaffold.d.ts +42 -0
- package/dist/commerce/checkout-scaffold.js +306 -0
- package/dist/commerce/checkout.d.ts +72 -0
- package/dist/commerce/checkout.js +124 -0
- package/dist/commerce/index.d.ts +8 -0
- package/dist/commerce/index.js +9 -0
- package/dist/commerce/loader.d.ts +71 -0
- package/dist/commerce/loader.js +90 -0
- package/dist/commerce/mirror.d.ts +67 -0
- package/dist/commerce/mirror.js +203 -0
- package/dist/commerce/roles.d.ts +38 -0
- package/dist/commerce/roles.js +93 -0
- package/dist/commerce/secrets.d.ts +74 -0
- package/dist/commerce/secrets.js +129 -0
- package/dist/commerce/sync.d.ts +86 -0
- package/dist/commerce/sync.js +154 -0
- package/dist/components/sections.d.ts +577 -0
- package/dist/components/sections.js +425 -0
- package/dist/config.d.ts +174 -12
- package/dist/config.js +43 -1
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.js +5 -0
- package/dist/email/inquiry.d.ts +33 -0
- package/dist/email/inquiry.js +63 -0
- package/dist/email/send.d.ts +120 -0
- package/dist/email/send.js +196 -0
- package/dist/email/templates.d.ts +24 -0
- package/dist/email/templates.js +184 -0
- package/dist/email/theme.d.ts +24 -0
- package/dist/email/theme.js +150 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/map/index.d.ts +3 -0
- package/dist/map/index.js +4 -0
- package/dist/map/pmtiles.d.ts +92 -0
- package/dist/map/pmtiles.js +130 -0
- package/dist/map/scaffold.d.ts +29 -0
- package/dist/map/scaffold.js +212 -0
- package/dist/map/style.d.ts +58 -0
- package/dist/map/style.js +154 -0
- package/dist/portal/config.d.ts +26 -0
- package/dist/portal/config.js +50 -0
- package/dist/portal/guard.d.ts +48 -0
- package/dist/portal/guard.js +64 -0
- package/dist/portal/index.d.ts +5 -0
- package/dist/portal/index.js +6 -0
- package/dist/portal/nav.d.ts +26 -0
- package/dist/portal/nav.js +35 -0
- package/dist/portal/scaffold.d.ts +28 -0
- package/dist/portal/scaffold.js +140 -0
- package/dist/portal/session.d.ts +36 -0
- package/dist/portal/session.js +86 -0
- package/dist/portfolio/index.d.ts +1 -0
- package/dist/portfolio/index.js +4 -0
- package/dist/portfolio/scaffold.d.ts +9 -0
- package/dist/portfolio/scaffold.js +93 -0
- package/dist/project/actions.d.ts +3 -0
- package/dist/project/actions.js +106 -0
- package/dist/project/generate.d.ts +15 -0
- package/dist/project/generate.js +144 -2
- package/dist/project/index.d.ts +2 -0
- package/dist/project/index.js +2 -0
- package/dist/project/scaffold.d.ts +29 -0
- package/dist/project/scaffold.js +140 -0
- package/dist/pwa/generate.d.ts +49 -0
- package/dist/pwa/generate.js +218 -0
- package/dist/pwa/index.d.ts +1 -0
- package/dist/pwa/index.js +2 -0
- package/dist/queues/consumer.d.ts +29 -0
- package/dist/queues/consumer.js +37 -0
- package/dist/queues/index.d.ts +4 -0
- package/dist/queues/index.js +5 -0
- package/dist/queues/messages.d.ts +60 -0
- package/dist/queues/messages.js +71 -0
- package/dist/queues/scaffold.d.ts +44 -0
- package/dist/queues/scaffold.js +204 -0
- package/dist/queues/webhook.d.ts +60 -0
- package/dist/queues/webhook.js +81 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +4 -0
- package/dist/realtime/scaffold.d.ts +30 -0
- package/dist/realtime/scaffold.js +159 -0
- package/dist/schema/collections.d.ts +41 -7
- package/dist/schema/collections.js +101 -12
- package/dist/schema/generate.js +10 -1
- package/dist/secrets.d.ts +54 -0
- package/dist/secrets.js +80 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/rate-rules.d.ts +21 -0
- package/dist/security/rate-rules.js +107 -0
- package/dist/seo/index.d.ts +3 -0
- package/dist/seo/index.js +4 -0
- package/dist/seo/resolve.d.ts +68 -0
- package/dist/seo/resolve.js +73 -0
- package/dist/seo/routes.d.ts +44 -0
- package/dist/seo/routes.js +104 -0
- package/dist/seo/structured-data.d.ts +51 -0
- package/dist/seo/structured-data.js +105 -0
- package/dist/status.d.ts +51 -0
- package/dist/status.js +113 -0
- package/dist/worker/generate.d.ts +18 -10
- package/dist/worker/generate.js +325 -37
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +42 -0
- package/dist/workflow/advance.d.ts +102 -0
- package/dist/workflow/advance.js +145 -0
- package/dist/workflow/config.d.ts +60 -0
- package/dist/workflow/config.js +73 -0
- package/dist/workflow/generate.d.ts +22 -0
- package/dist/workflow/generate.js +138 -0
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.js +4 -0
- package/package.json +21 -4
- package/src/components/Editable.astro +33 -9
- package/src/components/JustifiedGallery.astro +254 -0
- package/src/components/MediaSlot.astro +178 -0
- package/src/components/PortalShell.astro +80 -0
- package/src/components/RegisterSW.astro +45 -0
- package/src/components/Section.astro +101 -35
- package/src/components/Sections.astro +64 -0
- package/src/components/Seo.astro +57 -0
- package/src/components/StageBar.astro +137 -0
- package/src/components/StructuredData.astro +33 -0
- package/src/components/justify.ts +170 -0
- package/src/components/media-meta.ts +174 -0
- package/src/components/sections/AboutIntro.astro +46 -0
- package/src/components/sections/Banner.astro +31 -0
- package/src/components/sections/Contact.astro +22 -9
- package/src/components/sections/Cta.astro +33 -10
- package/src/components/sections/Faq.astro +50 -0
- package/src/components/sections/FeatureGrid.astro +40 -11
- package/src/components/sections/Gallery.astro +46 -0
- package/src/components/sections/Hero.astro +40 -12
- package/src/components/sections/LocationHours.astro +59 -0
- package/src/components/sections/Media.astro +44 -0
- package/src/components/sections/PricingTiers.astro +79 -0
- package/src/components/sections/ProductGrid.astro +73 -0
- package/src/components/sections/SplitImage.astro +61 -0
- package/src/components/sections/Steps.astro +58 -0
- package/src/components/sections/Testimonial.astro +51 -0
- package/src/components/sections.ts +452 -67
|
@@ -0,0 +1,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} →` }),
|
|
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)} · ${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’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’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’t request this, you can safely ignore this email — your password won’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’ve got a new message.",
|
|
131
|
+
bodyHtml,
|
|
132
|
+
footerNote: `Reply goes to ${safeEmail} · 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)} — 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 — 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 — we’ve got your message.",
|
|
171
|
+
bodyHtml,
|
|
172
|
+
footerNote: "Automated confirmation · 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,19 @@
|
|
|
1
|
+
export * from "./analytics/index.js";
|
|
2
|
+
export * from "./commerce/index.js";
|
|
1
3
|
export * from "./config.js";
|
|
4
|
+
export * from "./email/index.js";
|
|
2
5
|
export * from "./errors.js";
|
|
6
|
+
export * from "./secrets.js";
|
|
7
|
+
export * from "./map/index.js";
|
|
8
|
+
export * from "./portal/index.js";
|
|
9
|
+
export * from "./portfolio/index.js";
|
|
3
10
|
export * from "./project/index.js";
|
|
11
|
+
export * from "./pwa/index.js";
|
|
12
|
+
export * from "./realtime/index.js";
|
|
13
|
+
export * from "./queues/index.js";
|
|
4
14
|
export * from "./schema/index.js";
|
|
15
|
+
export * from "./security/index.js";
|
|
16
|
+
export * from "./seo/index.js";
|
|
17
|
+
export * from "./status.js";
|
|
5
18
|
export * from "./worker/index.js";
|
|
19
|
+
export * from "./workflow/index.js";
|
package/dist/index.js
CHANGED
|
@@ -3,8 +3,22 @@
|
|
|
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 "./commerce/index.js";
|
|
6
8
|
export * from "./config.js";
|
|
9
|
+
export * from "./email/index.js";
|
|
7
10
|
export * from "./errors.js";
|
|
11
|
+
export * from "./secrets.js";
|
|
12
|
+
export * from "./map/index.js";
|
|
13
|
+
export * from "./portal/index.js";
|
|
14
|
+
export * from "./portfolio/index.js";
|
|
8
15
|
export * from "./project/index.js";
|
|
16
|
+
export * from "./pwa/index.js";
|
|
17
|
+
export * from "./realtime/index.js";
|
|
18
|
+
export * from "./queues/index.js";
|
|
9
19
|
export * from "./schema/index.js";
|
|
20
|
+
export * from "./security/index.js";
|
|
21
|
+
export * from "./seo/index.js";
|
|
22
|
+
export * from "./status.js";
|
|
10
23
|
export * from "./worker/index.js";
|
|
24
|
+
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";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
export { ASTROID_MAP_DEPENDENCIES, ASTROID_PMTILES_KEY, ASTROID_PMTILES_PATH, generateMapEmbedComponent, generateMapTileRoute, usesMap, } from "./scaffold.js";
|
|
3
|
+
export { parseRangeHeader, servePmtiles, } from "./pmtiles.js";
|
|
4
|
+
export { astroidMapStyle } from "./style.js";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A byte range, shaped to be assignable to Cloudflare's `R2Range`.
|
|
3
|
+
*
|
|
4
|
+
* All-optional fields would NOT be: `R2Range` is a union whose members each
|
|
5
|
+
* require something (`{offset, length?}` | `{suffix}`), so a loose shape
|
|
6
|
+
* satisfies none of them.
|
|
7
|
+
*/
|
|
8
|
+
export type RangeSpec = {
|
|
9
|
+
offset: number;
|
|
10
|
+
length?: number;
|
|
11
|
+
} | {
|
|
12
|
+
suffix: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* How the archive is read. A function rather than a bucket interface, and
|
|
16
|
+
* deliberately so: `R2Bucket.get` is overloaded, and its first overload
|
|
17
|
+
* *requires* an options argument — which means no structural interface with an
|
|
18
|
+
* optional second parameter can accept a real `R2Bucket`. Taking a reader lets
|
|
19
|
+
* the call site use R2's own types and resolves the mismatch at the source, and
|
|
20
|
+
* incidentally makes this work over any storage rather than only R2.
|
|
21
|
+
*/
|
|
22
|
+
export type RangeReader = (range?: RangeSpec) => Promise<RangeObject | null>;
|
|
23
|
+
export interface RangeObject {
|
|
24
|
+
/**
|
|
25
|
+
* Optional because `R2Bucket.get` is overloaded and its conditional form
|
|
26
|
+
* resolves to `R2Object`, which carries no body at all. Requiring one here
|
|
27
|
+
* makes a real `R2Bucket` fail to satisfy this interface.
|
|
28
|
+
*/
|
|
29
|
+
body?: ReadableStream | null;
|
|
30
|
+
/** Size of the WHOLE object, not the returned slice. */
|
|
31
|
+
size: number;
|
|
32
|
+
/** What R2 actually returned — it clamps a range that runs past the end. */
|
|
33
|
+
range?: {
|
|
34
|
+
offset?: number;
|
|
35
|
+
length?: number;
|
|
36
|
+
suffix?: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** A parsed `Range` header. */
|
|
40
|
+
export type ParsedRange = {
|
|
41
|
+
kind: "range";
|
|
42
|
+
offset: number;
|
|
43
|
+
length?: number;
|
|
44
|
+
} | {
|
|
45
|
+
kind: "suffix";
|
|
46
|
+
suffix: number;
|
|
47
|
+
}
|
|
48
|
+
/** Syntactically valid but unsatisfiable against this object → 416. */
|
|
49
|
+
| {
|
|
50
|
+
kind: "unsatisfiable";
|
|
51
|
+
}
|
|
52
|
+
/** Absent or unparseable → serve the whole object (per RFC 9110, an
|
|
53
|
+
* unparseable Range is ignored rather than rejected). */
|
|
54
|
+
| null;
|
|
55
|
+
/**
|
|
56
|
+
* Parse a `Range` header into something R2 understands.
|
|
57
|
+
*
|
|
58
|
+
* Handles the three forms that matter:
|
|
59
|
+
* `bytes=0-1023` a bounded window
|
|
60
|
+
* `bytes=1024-` open-ended, to the end
|
|
61
|
+
* `bytes=-20000` the LAST n bytes — the one the reference dropped
|
|
62
|
+
*
|
|
63
|
+
* Multi-range (`bytes=0-99,200-299`) returns null: it requires a multipart
|
|
64
|
+
* response no PMTiles client asks for, and serving the whole object is the
|
|
65
|
+
* spec-legal fallback.
|
|
66
|
+
*/
|
|
67
|
+
export declare function parseRangeHeader(header: string | null, size: number): ParsedRange;
|
|
68
|
+
export interface PmtilesHandlerOptions {
|
|
69
|
+
/** Reads the archive, whole or by range. See {@link RangeReader}. */
|
|
70
|
+
read: RangeReader;
|
|
71
|
+
/**
|
|
72
|
+
* `Cache-Control` for the response. An archive is immutable — a re-clip
|
|
73
|
+
* overwrites the object wholesale — so the byte ranges cache hard at the
|
|
74
|
+
* edge. Default one day.
|
|
75
|
+
*/
|
|
76
|
+
cacheControl?: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Serve a PMTiles archive from R2 with range support.
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* export const GET: APIRoute = ({ request }) =>
|
|
83
|
+
* servePmtiles(request, {
|
|
84
|
+
* read: (range) => env.MEDIA.get(KEY, range ? { range } : undefined),
|
|
85
|
+
* });
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* A missing archive is a 404, not an error: the map module is usable before
|
|
89
|
+
* anyone has uploaded a basemap (the canvas renders its background and pin),
|
|
90
|
+
* which is the same dormant-until-provisioned posture as every other module.
|
|
91
|
+
*/
|
|
92
|
+
export declare function servePmtiles(request: Request, options: PmtilesHandlerOptions): Promise<Response>;
|