astroidjs 0.1.1 → 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 +42 -8
- package/dist/schema/collections.js +102 -8
- 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 -5
- 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,130 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Byte-range serving from R2 — the plumbing under the self-hosted basemap.
|
|
4
|
+
//
|
|
5
|
+
// A PMTiles archive is one immutable blob, often hundreds of megabytes, and the
|
|
6
|
+
// client reads a few kilobytes at a time: a header, then directory pages, then
|
|
7
|
+
// individual tiles. That only works if the server honours HTTP range requests,
|
|
8
|
+
// which is the whole reason this file exists rather than a plain R2 passthrough.
|
|
9
|
+
//
|
|
10
|
+
// Serving it same-origin (rather than off a media domain) is what keeps the CSP
|
|
11
|
+
// at `connect-src 'self'` with no external tile host and no API key, and
|
|
12
|
+
// sidesteps any CORS policy on the bucket.
|
|
13
|
+
//
|
|
14
|
+
// The range parsing is deliberately complete. The implementation this
|
|
15
|
+
// generalizes matched only `bytes=<start>-<end?>`, so a SUFFIX range
|
|
16
|
+
// (`bytes=-20000`, "the last 20 KB" — how a client reads a footer without
|
|
17
|
+
// knowing the length) fell through to serving the ENTIRE archive. That is a
|
|
18
|
+
// correct-looking response and a catastrophic one.
|
|
19
|
+
/**
|
|
20
|
+
* Parse a `Range` header into something R2 understands.
|
|
21
|
+
*
|
|
22
|
+
* Handles the three forms that matter:
|
|
23
|
+
* `bytes=0-1023` a bounded window
|
|
24
|
+
* `bytes=1024-` open-ended, to the end
|
|
25
|
+
* `bytes=-20000` the LAST n bytes — the one the reference dropped
|
|
26
|
+
*
|
|
27
|
+
* Multi-range (`bytes=0-99,200-299`) returns null: it requires a multipart
|
|
28
|
+
* response no PMTiles client asks for, and serving the whole object is the
|
|
29
|
+
* spec-legal fallback.
|
|
30
|
+
*/
|
|
31
|
+
export function parseRangeHeader(header, size) {
|
|
32
|
+
if (!header)
|
|
33
|
+
return null;
|
|
34
|
+
const value = header.trim();
|
|
35
|
+
if (!value.startsWith("bytes="))
|
|
36
|
+
return null;
|
|
37
|
+
const spec = value.slice("bytes=".length).trim();
|
|
38
|
+
if (spec.includes(","))
|
|
39
|
+
return null;
|
|
40
|
+
const suffix = /^-(\d+)$/.exec(spec);
|
|
41
|
+
if (suffix) {
|
|
42
|
+
const n = Number(suffix[1]);
|
|
43
|
+
// "last 0 bytes" is unsatisfiable per RFC 9110; a suffix larger than the
|
|
44
|
+
// object legally means the whole object.
|
|
45
|
+
if (n === 0)
|
|
46
|
+
return { kind: "unsatisfiable" };
|
|
47
|
+
return { kind: "suffix", suffix: Math.min(n, size) };
|
|
48
|
+
}
|
|
49
|
+
const bounded = /^(\d+)-(\d*)$/.exec(spec);
|
|
50
|
+
if (!bounded)
|
|
51
|
+
return null;
|
|
52
|
+
const start = Number(bounded[1]);
|
|
53
|
+
if (start >= size)
|
|
54
|
+
return { kind: "unsatisfiable" };
|
|
55
|
+
if (bounded[2] === "")
|
|
56
|
+
return { kind: "range", offset: start };
|
|
57
|
+
const end = Number(bounded[2]);
|
|
58
|
+
if (end < start)
|
|
59
|
+
return { kind: "unsatisfiable" };
|
|
60
|
+
// An end past the object is clamped, not an error — a client asking for more
|
|
61
|
+
// than exists gets what exists.
|
|
62
|
+
return { kind: "range", offset: start, length: Math.min(end, size - 1) - start + 1 };
|
|
63
|
+
}
|
|
64
|
+
const CONTENT_TYPE = "application/octet-stream";
|
|
65
|
+
/**
|
|
66
|
+
* Serve a PMTiles archive from R2 with range support.
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* export const GET: APIRoute = ({ request }) =>
|
|
70
|
+
* servePmtiles(request, {
|
|
71
|
+
* read: (range) => env.MEDIA.get(KEY, range ? { range } : undefined),
|
|
72
|
+
* });
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* A missing archive is a 404, not an error: the map module is usable before
|
|
76
|
+
* anyone has uploaded a basemap (the canvas renders its background and pin),
|
|
77
|
+
* which is the same dormant-until-provisioned posture as every other module.
|
|
78
|
+
*/
|
|
79
|
+
export async function servePmtiles(request, options) {
|
|
80
|
+
const { read, cacheControl = "public, max-age=86400" } = options;
|
|
81
|
+
const headers = (extra) => ({
|
|
82
|
+
"content-type": CONTENT_TYPE,
|
|
83
|
+
"accept-ranges": "bytes",
|
|
84
|
+
"cache-control": cacheControl,
|
|
85
|
+
...extra,
|
|
86
|
+
});
|
|
87
|
+
// HEAD is how a client discovers the length before ranging into the archive.
|
|
88
|
+
const head = request.method === "HEAD";
|
|
89
|
+
const rangeHeader = request.headers.get("range");
|
|
90
|
+
// Without the size, an unsatisfiable range can't be told from a valid one, so
|
|
91
|
+
// the object is fetched first when a range is present.
|
|
92
|
+
const probe = rangeHeader ? await read() : null;
|
|
93
|
+
if (rangeHeader && !probe)
|
|
94
|
+
return new Response("Basemap not found", { status: 404 });
|
|
95
|
+
const size = probe?.size ?? 0;
|
|
96
|
+
const parsed = rangeHeader ? parseRangeHeader(rangeHeader, size) : null;
|
|
97
|
+
if (parsed?.kind === "unsatisfiable") {
|
|
98
|
+
return new Response(null, {
|
|
99
|
+
status: 416,
|
|
100
|
+
// RFC 9110: a 416 states the object's real length so the client can retry.
|
|
101
|
+
headers: headers({ "content-range": `bytes */${size}` }),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (parsed) {
|
|
105
|
+
const object = await read(parsed.kind === "suffix"
|
|
106
|
+
? { suffix: parsed.suffix }
|
|
107
|
+
: { offset: parsed.offset, ...(parsed.length ? { length: parsed.length } : {}) });
|
|
108
|
+
if (!object)
|
|
109
|
+
return new Response("Basemap not found", { status: 404 });
|
|
110
|
+
// Trust what R2 says it returned rather than what was asked for — it clamps
|
|
111
|
+
// ranges, and a Content-Range that disagrees with the body corrupts the
|
|
112
|
+
// client's view of the archive.
|
|
113
|
+
const got = object.range ?? {};
|
|
114
|
+
const start = got.offset ?? (parsed.kind === "suffix" ? object.size - parsed.suffix : parsed.offset);
|
|
115
|
+
const length = got.length ?? (got.suffix ?? object.size - start);
|
|
116
|
+
return new Response(head ? null : (object.body ?? null), {
|
|
117
|
+
status: 206,
|
|
118
|
+
headers: headers({
|
|
119
|
+
"content-range": `bytes ${start}-${start + length - 1}/${object.size}`,
|
|
120
|
+
"content-length": String(length),
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const object = await read();
|
|
125
|
+
if (!object)
|
|
126
|
+
return new Response("Basemap not found", { status: 404 });
|
|
127
|
+
return new Response(head ? null : (object.body ?? null), {
|
|
128
|
+
headers: headers({ "content-length": String(object.size) }),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AstroidConfig } from "../config.js";
|
|
2
|
+
/** Default R2 object key for the basemap archive. */
|
|
3
|
+
export declare const ASTROID_PMTILES_KEY = "map/basemap.pmtiles";
|
|
4
|
+
/** Default route the archive is served from, same-origin. */
|
|
5
|
+
export declare const ASTROID_PMTILES_PATH = "/map/basemap.pmtiles";
|
|
6
|
+
/** True when this project switched the map module on. */
|
|
7
|
+
export declare const usesMap: (config: AstroidConfig) => boolean;
|
|
8
|
+
/**
|
|
9
|
+
* `src/pages/map/basemap.pmtiles.ts` — the range-serving tile route.
|
|
10
|
+
*
|
|
11
|
+
* Thin: `servePmtiles` owns range parsing, the 206/416 contract, and trusting
|
|
12
|
+
* R2's clamped range over the requested one. What's here is which bucket and
|
|
13
|
+
* which key.
|
|
14
|
+
*/
|
|
15
|
+
export declare function generateMapTileRoute(config: AstroidConfig): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* `src/components/MapEmbed.astro` — the map itself.
|
|
18
|
+
*
|
|
19
|
+
* The lazy load is not an optimisation detail, it's the reason this is usable:
|
|
20
|
+
* MapLibre is ~1 MB, and a location map is almost always below the fold. The
|
|
21
|
+
* library is fetched only once a container nears the viewport, and memoized so
|
|
22
|
+
* two maps on a page download it once.
|
|
23
|
+
*/
|
|
24
|
+
export declare function generateMapEmbedComponent(config: AstroidConfig): string | null;
|
|
25
|
+
/** The npm dependencies the map module needs in the scaffold. */
|
|
26
|
+
export declare const ASTROID_MAP_DEPENDENCIES: {
|
|
27
|
+
readonly "maplibre-gl": "^5.9.0";
|
|
28
|
+
readonly pmtiles: "^4.4.0";
|
|
29
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The map module's scaffold-once files: the tile route and `<MapEmbed>`.
|
|
4
|
+
//
|
|
5
|
+
// GENERATED rather than shipped as a component, for a concrete reason. MapLibre
|
|
6
|
+
// GL is ~1 MB and `pmtiles` is its companion; a `MapEmbed.astro` living in
|
|
7
|
+
// astroid's own `src/components/` would make both a hard requirement of the
|
|
8
|
+
// package — every project installing them, and the CI probe that type-checks
|
|
9
|
+
// the component library needing them too — for a feature most sites never turn
|
|
10
|
+
// on. Generating the component into the projects that enable the module keeps
|
|
11
|
+
// the dependency where the decision was made.
|
|
12
|
+
//
|
|
13
|
+
// It also puts the pin, the gesture handling, and the placeholder in the
|
|
14
|
+
// project's hands, which is right: those are brand, and brand is site-owned.
|
|
15
|
+
/** Default R2 object key for the basemap archive. */
|
|
16
|
+
export const ASTROID_PMTILES_KEY = "map/basemap.pmtiles";
|
|
17
|
+
/** Default route the archive is served from, same-origin. */
|
|
18
|
+
export const ASTROID_PMTILES_PATH = "/map/basemap.pmtiles";
|
|
19
|
+
/** True when this project switched the map module on. */
|
|
20
|
+
export const usesMap = (config) => (config.modules ?? []).includes("map");
|
|
21
|
+
/**
|
|
22
|
+
* `src/pages/map/basemap.pmtiles.ts` — the range-serving tile route.
|
|
23
|
+
*
|
|
24
|
+
* Thin: `servePmtiles` owns range parsing, the 206/416 contract, and trusting
|
|
25
|
+
* R2's clamped range over the requested one. What's here is which bucket and
|
|
26
|
+
* which key.
|
|
27
|
+
*/
|
|
28
|
+
export function generateMapTileRoute(config) {
|
|
29
|
+
if (!usesMap(config))
|
|
30
|
+
return null;
|
|
31
|
+
return [
|
|
32
|
+
"// Serves the self-hosted Protomaps basemap (one PMTiles archive) from R2,",
|
|
33
|
+
"// same-origin, with HTTP range support — the client reads only the byte",
|
|
34
|
+
"// ranges it needs per tile.",
|
|
35
|
+
"//",
|
|
36
|
+
"// Same-origin is the point: no external tile host and no API key means the",
|
|
37
|
+
"// CSP stays at `connect-src 'self'`, and any CORS policy on the bucket is",
|
|
38
|
+
"// irrelevant.",
|
|
39
|
+
"//",
|
|
40
|
+
"// Upload an archive to this key before the map draws anything (until then",
|
|
41
|
+
"// the canvas shows its background and pin — the module is dormant, not",
|
|
42
|
+
"// broken):",
|
|
43
|
+
`// wrangler r2 object put ${config.key}-media/${ASTROID_PMTILES_KEY} \\`,
|
|
44
|
+
"// --file=extract.pmtiles --content-type=application/octet-stream --remote",
|
|
45
|
+
"//",
|
|
46
|
+
"// Build an extract for your area with the Protomaps CLI:",
|
|
47
|
+
"// pmtiles extract https://build.protomaps.com/<date>.pmtiles extract.pmtiles \\",
|
|
48
|
+
"// --bbox=<minLon,minLat,maxLon,maxLat>",
|
|
49
|
+
'import type { APIRoute } from "astro";',
|
|
50
|
+
'import { servePmtiles } from "astroidjs";',
|
|
51
|
+
'import { env } from "cloudflare:workers";',
|
|
52
|
+
"",
|
|
53
|
+
"export const prerender = false;",
|
|
54
|
+
"",
|
|
55
|
+
`const KEY = ${JSON.stringify(ASTROID_PMTILES_KEY)};`,
|
|
56
|
+
"",
|
|
57
|
+
"export const GET: APIRoute = ({ request }) =>",
|
|
58
|
+
" servePmtiles(request, {",
|
|
59
|
+
" // The reader is a closure rather than the bucket itself: R2Bucket.get is",
|
|
60
|
+
" // overloaded with a required-options first signature, which no structural",
|
|
61
|
+
" // interface can accept. Calling it here uses R2's own types.",
|
|
62
|
+
" read: (range) => env.MEDIA.get(KEY, range ? { range } : undefined),",
|
|
63
|
+
" });",
|
|
64
|
+
"",
|
|
65
|
+
"// HEAD lets a client learn the archive's length before ranging into it.",
|
|
66
|
+
"export const HEAD = GET;",
|
|
67
|
+
"",
|
|
68
|
+
].join("\n");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `src/components/MapEmbed.astro` — the map itself.
|
|
72
|
+
*
|
|
73
|
+
* The lazy load is not an optimisation detail, it's the reason this is usable:
|
|
74
|
+
* MapLibre is ~1 MB, and a location map is almost always below the fold. The
|
|
75
|
+
* library is fetched only once a container nears the viewport, and memoized so
|
|
76
|
+
* two maps on a page download it once.
|
|
77
|
+
*/
|
|
78
|
+
export function generateMapEmbedComponent(config) {
|
|
79
|
+
if (!usesMap(config))
|
|
80
|
+
return null;
|
|
81
|
+
const brand = config.theme.colors.brand;
|
|
82
|
+
return [
|
|
83
|
+
"---",
|
|
84
|
+
"// A MapLibre map over the self-hosted PMTiles basemap, centred on exact",
|
|
85
|
+
"// coordinates. Scaffolded once; yours to edit — the pin, the gestures, and",
|
|
86
|
+
"// the placeholder are brand decisions.",
|
|
87
|
+
"//",
|
|
88
|
+
"// Coordinates, never a geocoded address string: geocoding at render time is",
|
|
89
|
+
"// a network call that can fail, drift, or land on the wrong side of the",
|
|
90
|
+
"// street. Renders the slot (your placeholder) when they're missing.",
|
|
91
|
+
'import { astroidMapStyle } from "astroidjs";',
|
|
92
|
+
"",
|
|
93
|
+
"interface Props {",
|
|
94
|
+
" // Section fields arrive as strings; a literal number is fine too.",
|
|
95
|
+
" lat?: number | string;",
|
|
96
|
+
" lng?: number | string;",
|
|
97
|
+
" zoom?: number;",
|
|
98
|
+
" aspect?: string;",
|
|
99
|
+
"}",
|
|
100
|
+
"",
|
|
101
|
+
'const { lat, lng, zoom = 15, aspect = "4/3" } = Astro.props;',
|
|
102
|
+
'const num = (v: number | string | undefined) => (v === "" || v == null ? Number.NaN : Number(v));',
|
|
103
|
+
"const nlat = num(lat);",
|
|
104
|
+
"const nlng = num(lng);",
|
|
105
|
+
"const show = Number.isFinite(nlat) && Number.isFinite(nlng);",
|
|
106
|
+
"",
|
|
107
|
+
"// Built here and handed to the client as data, so the brand palette lives in",
|
|
108
|
+
"// one place instead of being duplicated into the script.",
|
|
109
|
+
"const style = JSON.stringify(",
|
|
110
|
+
" astroidMapStyle({",
|
|
111
|
+
` pmtilesUrl: ${JSON.stringify(ASTROID_PMTILES_PATH)},`,
|
|
112
|
+
` colors: { water: ${JSON.stringify(brand)} },`,
|
|
113
|
+
" }),",
|
|
114
|
+
");",
|
|
115
|
+
"---",
|
|
116
|
+
"",
|
|
117
|
+
"{",
|
|
118
|
+
" show ? (",
|
|
119
|
+
' <div',
|
|
120
|
+
' class="astroid-map"',
|
|
121
|
+
" data-astroid-map",
|
|
122
|
+
" data-lat={nlat}",
|
|
123
|
+
" data-lng={nlng}",
|
|
124
|
+
" data-zoom={zoom}",
|
|
125
|
+
" data-style={style}",
|
|
126
|
+
" style={`aspect-ratio:${aspect}`}",
|
|
127
|
+
" />",
|
|
128
|
+
" ) : (",
|
|
129
|
+
" <slot />",
|
|
130
|
+
" )",
|
|
131
|
+
"}",
|
|
132
|
+
"",
|
|
133
|
+
"<style>",
|
|
134
|
+
" .astroid-map {",
|
|
135
|
+
" overflow: hidden;",
|
|
136
|
+
" border-radius: 1rem;",
|
|
137
|
+
` background: ${brand}1a;`,
|
|
138
|
+
" }",
|
|
139
|
+
"</style>",
|
|
140
|
+
"",
|
|
141
|
+
"<script>",
|
|
142
|
+
" // MapLibre is ~1 MB and a location map is almost always below the fold, so",
|
|
143
|
+
" // nothing is downloaded until a container nears the viewport. The loader is",
|
|
144
|
+
" // memoized: two maps on a page fetch the library once.",
|
|
145
|
+
' const els = [...document.querySelectorAll<HTMLElement>("[data-astroid-map]")];',
|
|
146
|
+
"",
|
|
147
|
+
" if (els.length > 0) {",
|
|
148
|
+
" let loader: Promise<{ maplibregl: any; Protocol: any }> | null = null;",
|
|
149
|
+
" const load = () =>",
|
|
150
|
+
" (loader ??= Promise.all([",
|
|
151
|
+
' import("maplibre-gl"),',
|
|
152
|
+
' import("pmtiles"),',
|
|
153
|
+
' import("maplibre-gl/dist/maplibre-gl.css"),',
|
|
154
|
+
" ]).then(([maplibre, pmtiles]) => ({",
|
|
155
|
+
" // maplibre-gl ships as CJS: under ESM interop the API may be on",
|
|
156
|
+
" // `.default` or be the namespace itself. Accept either.",
|
|
157
|
+
" maplibregl: (maplibre as any).default ?? maplibre,",
|
|
158
|
+
" Protocol: (pmtiles as any).Protocol,",
|
|
159
|
+
" })));",
|
|
160
|
+
"",
|
|
161
|
+
" const init = async (el: HTMLElement) => {",
|
|
162
|
+
' const lat = Number(el.dataset.lat);',
|
|
163
|
+
' const lng = Number(el.dataset.lng);',
|
|
164
|
+
" if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;",
|
|
165
|
+
"",
|
|
166
|
+
" const { maplibregl, Protocol } = await load();",
|
|
167
|
+
' // Register the pmtiles:// protocol once so MapLibre range-reads the',
|
|
168
|
+
" // archive instead of requesting tile URLs.",
|
|
169
|
+
' maplibregl.addProtocol("pmtiles", new Protocol().tile);',
|
|
170
|
+
"",
|
|
171
|
+
" const map = new maplibregl.Map({",
|
|
172
|
+
" container: el,",
|
|
173
|
+
' style: JSON.parse(el.dataset.style ?? "{}"),',
|
|
174
|
+
" center: [lng, lat],",
|
|
175
|
+
' zoom: Number(el.dataset.zoom ?? 15),',
|
|
176
|
+
" // A map that swallows page scroll is a trap on mobile; cooperative",
|
|
177
|
+
" // gestures require an explicit modifier/two fingers to pan.",
|
|
178
|
+
" cooperativeGestures: true,",
|
|
179
|
+
" dragRotate: false,",
|
|
180
|
+
" pitchWithRotate: false,",
|
|
181
|
+
" touchPitch: false,",
|
|
182
|
+
" });",
|
|
183
|
+
' map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");',
|
|
184
|
+
"",
|
|
185
|
+
" const pin = document.createElement(\"div\");",
|
|
186
|
+
" pin.style.cssText =",
|
|
187
|
+
` "width:18px;height:18px;border-radius:999px;background:${brand};" +`,
|
|
188
|
+
' "border:3px solid #fff;box-shadow:0 0 0 6px rgba(0,0,0,0.12);";',
|
|
189
|
+
" new maplibregl.Marker({ element: pin }).setLngLat([lng, lat]).addTo(map);",
|
|
190
|
+
" };",
|
|
191
|
+
"",
|
|
192
|
+
" const io = new IntersectionObserver(",
|
|
193
|
+
" (entries, obs) => {",
|
|
194
|
+
" for (const entry of entries) {",
|
|
195
|
+
" if (!entry.isIntersecting) continue;",
|
|
196
|
+
" obs.unobserve(entry.target);",
|
|
197
|
+
" init(entry.target as HTMLElement);",
|
|
198
|
+
" }",
|
|
199
|
+
" },",
|
|
200
|
+
' { rootMargin: "200px" },',
|
|
201
|
+
" );",
|
|
202
|
+
" for (const el of els) io.observe(el);",
|
|
203
|
+
" }",
|
|
204
|
+
"</script>",
|
|
205
|
+
"",
|
|
206
|
+
].join("\n");
|
|
207
|
+
}
|
|
208
|
+
/** The npm dependencies the map module needs in the scaffold. */
|
|
209
|
+
export const ASTROID_MAP_DEPENDENCIES = {
|
|
210
|
+
"maplibre-gl": "^5.9.0",
|
|
211
|
+
pmtiles: "^4.4.0",
|
|
212
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Brand colours the style is built from. Every one has a sensible default, so
|
|
2
|
+
* `astroidMapStyle({ pmtilesUrl })` already produces a usable map. */
|
|
3
|
+
export interface MapColors {
|
|
4
|
+
/** Land / page background. */
|
|
5
|
+
land?: string;
|
|
6
|
+
water?: string;
|
|
7
|
+
/** Parks, woods, scrub. */
|
|
8
|
+
green?: string;
|
|
9
|
+
/** Minor roads. */
|
|
10
|
+
road?: string;
|
|
11
|
+
/** Arterials and highways. */
|
|
12
|
+
arterial?: string;
|
|
13
|
+
/** Hairline road edges. */
|
|
14
|
+
casing?: string;
|
|
15
|
+
building?: string;
|
|
16
|
+
boundary?: string;
|
|
17
|
+
/** Label ink, when `glyphs` is supplied. */
|
|
18
|
+
label?: string;
|
|
19
|
+
/** Label halo, when `glyphs` is supplied. */
|
|
20
|
+
labelHalo?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface MapStyleOptions {
|
|
23
|
+
/**
|
|
24
|
+
* URL of the archive, same-origin. Passed to MapLibre as `pmtiles://<url>`,
|
|
25
|
+
* which the `pmtiles` protocol handler turns into range reads.
|
|
26
|
+
*/
|
|
27
|
+
pmtilesUrl: string;
|
|
28
|
+
colors?: MapColors;
|
|
29
|
+
/**
|
|
30
|
+
* SDF glyph URL template (e.g. `"/map/fonts/{fontstack}/{range}.pbf"`).
|
|
31
|
+
* Omit for an unlabelled map — which is the honest default, since labels
|
|
32
|
+
* without self-hosted glyphs mean an external font host and a looser CSP.
|
|
33
|
+
*/
|
|
34
|
+
glyphs?: string;
|
|
35
|
+
/** Font stack for labels. Only used when `glyphs` is set. */
|
|
36
|
+
fontstack?: string;
|
|
37
|
+
/** Attribution shown in the corner. Protomaps basemaps derive from OSM, and
|
|
38
|
+
* the licence requires the credit — so it defaults to present, not absent. */
|
|
39
|
+
attribution?: string;
|
|
40
|
+
}
|
|
41
|
+
/** A MapLibre style. Typed loosely on purpose — see the header. */
|
|
42
|
+
export interface MapStyle {
|
|
43
|
+
version: 8;
|
|
44
|
+
glyphs?: string;
|
|
45
|
+
sources: Record<string, unknown>;
|
|
46
|
+
layers: Record<string, unknown>[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build a brand-recoloured basemap style over a self-hosted PMTiles archive.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* const style = astroidMapStyle({
|
|
53
|
+
* pmtilesUrl: `${location.origin}/map/basemap.pmtiles`,
|
|
54
|
+
* colors: { water: theme.colors.brand },
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function astroidMapStyle(options: MapStyleOptions): MapStyle;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// A MapLibre style, built from brand colours.
|
|
4
|
+
//
|
|
5
|
+
// Deliberately dependency-free: a style is JSON, so this returns a plain object
|
|
6
|
+
// rather than importing `maplibre-gl` (a megabyte) or `protomaps-themes-base`
|
|
7
|
+
// for its types. astroidjs stays installable by a project that will never draw
|
|
8
|
+
// a map, and a project that wants Protomaps' full maintained theme can swap
|
|
9
|
+
// this out — the only contract is "an object MapLibre accepts".
|
|
10
|
+
//
|
|
11
|
+
// The layer set is the quiet-basemap subset: land, water, green space, a road
|
|
12
|
+
// ramp with casings, buildings, and admin boundaries. Labels are opt-in and
|
|
13
|
+
// need `glyphs`, because self-hosted SDF glyph ranges are a provisioning step
|
|
14
|
+
// (and the reason the reference site could keep `font-src 'self'`).
|
|
15
|
+
//
|
|
16
|
+
// Source layer names follow the Protomaps v3 schema, which is what a `pmtiles`
|
|
17
|
+
// archive built with `pmtiles extract` / planetiler contains.
|
|
18
|
+
const DEFAULTS = {
|
|
19
|
+
land: "#faf7ef",
|
|
20
|
+
water: "#9fdad8",
|
|
21
|
+
green: "#e9efdc",
|
|
22
|
+
road: "#fffdf7",
|
|
23
|
+
arterial: "#f3eee0",
|
|
24
|
+
casing: "#ece5d2",
|
|
25
|
+
building: "#f0e9d9",
|
|
26
|
+
boundary: "#cfc6b0",
|
|
27
|
+
label: "#2a2a2a",
|
|
28
|
+
labelHalo: "#fffdf7",
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Build a brand-recoloured basemap style over a self-hosted PMTiles archive.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* const style = astroidMapStyle({
|
|
35
|
+
* pmtilesUrl: `${location.origin}/map/basemap.pmtiles`,
|
|
36
|
+
* colors: { water: theme.colors.brand },
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function astroidMapStyle(options) {
|
|
41
|
+
const c = { ...DEFAULTS, ...options.colors };
|
|
42
|
+
const { pmtilesUrl, glyphs, fontstack = "Noto Sans Regular", attribution = '<a href="https://openstreetmap.org">OpenStreetMap</a> · <a href="https://protomaps.com">Protomaps</a>', } = options;
|
|
43
|
+
const layers = [
|
|
44
|
+
{ id: "background", type: "background", paint: { "background-color": c.land } },
|
|
45
|
+
{
|
|
46
|
+
id: "earth",
|
|
47
|
+
type: "fill",
|
|
48
|
+
source: "protomaps",
|
|
49
|
+
"source-layer": "earth",
|
|
50
|
+
paint: { "fill-color": c.land },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "landuse",
|
|
54
|
+
type: "fill",
|
|
55
|
+
source: "protomaps",
|
|
56
|
+
"source-layer": "landuse",
|
|
57
|
+
// Only the green classes; leaving the rest unpainted keeps the map quiet
|
|
58
|
+
// rather than blotchy.
|
|
59
|
+
filter: ["in", "pmap:kind", "park", "forest", "wood", "grass", "scrub", "farmland"],
|
|
60
|
+
paint: { "fill-color": c.green },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "water",
|
|
64
|
+
type: "fill",
|
|
65
|
+
source: "protomaps",
|
|
66
|
+
"source-layer": "water",
|
|
67
|
+
paint: { "fill-color": c.water },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "buildings",
|
|
71
|
+
type: "fill",
|
|
72
|
+
source: "protomaps",
|
|
73
|
+
"source-layer": "buildings",
|
|
74
|
+
// Buildings only resolve once you're zoomed in; drawing them earlier is
|
|
75
|
+
// noise at city scale.
|
|
76
|
+
minzoom: 14,
|
|
77
|
+
paint: { "fill-color": c.building },
|
|
78
|
+
},
|
|
79
|
+
// Casings sit UNDER the road fills, so a road reads as a stroked ribbon
|
|
80
|
+
// rather than a flat band. Order matters more than colour here.
|
|
81
|
+
{
|
|
82
|
+
id: "roads-casing",
|
|
83
|
+
type: "line",
|
|
84
|
+
source: "protomaps",
|
|
85
|
+
"source-layer": "roads",
|
|
86
|
+
paint: {
|
|
87
|
+
"line-color": c.casing,
|
|
88
|
+
"line-width": ["interpolate", ["exponential", 1.6], ["zoom"], 10, 1.5, 16, 10],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "roads",
|
|
93
|
+
type: "line",
|
|
94
|
+
source: "protomaps",
|
|
95
|
+
"source-layer": "roads",
|
|
96
|
+
paint: {
|
|
97
|
+
"line-color": [
|
|
98
|
+
"match",
|
|
99
|
+
["get", "pmap:kind"],
|
|
100
|
+
"highway",
|
|
101
|
+
c.arterial,
|
|
102
|
+
"major_road",
|
|
103
|
+
c.arterial,
|
|
104
|
+
c.road,
|
|
105
|
+
],
|
|
106
|
+
"line-width": ["interpolate", ["exponential", 1.6], ["zoom"], 10, 0.8, 16, 7],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "boundaries",
|
|
111
|
+
type: "line",
|
|
112
|
+
source: "protomaps",
|
|
113
|
+
"source-layer": "boundaries",
|
|
114
|
+
paint: { "line-color": c.boundary, "line-width": 0.8, "line-dasharray": [3, 2] },
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
if (glyphs) {
|
|
118
|
+
layers.push({
|
|
119
|
+
id: "roads-labels",
|
|
120
|
+
type: "symbol",
|
|
121
|
+
source: "protomaps",
|
|
122
|
+
"source-layer": "roads",
|
|
123
|
+
minzoom: 14,
|
|
124
|
+
layout: {
|
|
125
|
+
"text-field": ["get", "name"],
|
|
126
|
+
"text-font": [fontstack],
|
|
127
|
+
"text-size": 11,
|
|
128
|
+
"symbol-placement": "line",
|
|
129
|
+
},
|
|
130
|
+
paint: { "text-color": c.label, "text-halo-color": c.labelHalo, "text-halo-width": 1.5 },
|
|
131
|
+
}, {
|
|
132
|
+
id: "places-labels",
|
|
133
|
+
type: "symbol",
|
|
134
|
+
source: "protomaps",
|
|
135
|
+
"source-layer": "places",
|
|
136
|
+
layout: { "text-field": ["get", "name"], "text-font": [fontstack], "text-size": 13 },
|
|
137
|
+
paint: { "text-color": c.label, "text-halo-color": c.labelHalo, "text-halo-width": 1.5 },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
version: 8,
|
|
142
|
+
...(glyphs ? { glyphs } : {}),
|
|
143
|
+
sources: {
|
|
144
|
+
protomaps: {
|
|
145
|
+
type: "vector",
|
|
146
|
+
// The `pmtiles://` prefix is what routes reads through the protocol
|
|
147
|
+
// handler the page registers, instead of MapLibre fetching tile URLs.
|
|
148
|
+
url: `pmtiles://${pmtilesUrl}`,
|
|
149
|
+
attribution,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
layers,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AstroidConfig } from "../config.js";
|
|
2
|
+
import type { PortalGuardConfig, PortalRoute } from "./guard.js";
|
|
3
|
+
/** Cookie prefix for the portal instance. Distinct from the studio's default
|
|
4
|
+
* (`better-auth`) so the two sessions can coexist on one origin. */
|
|
5
|
+
export declare const ASTROID_PORTAL_COOKIE_PREFIX = "portal";
|
|
6
|
+
/** Table-name prefix for the portal's Better Auth tables — `portal_user`,
|
|
7
|
+
* `portal_session`, … The studio owns the unprefixed names. */
|
|
8
|
+
export declare const ASTROID_PORTAL_TABLE_PREFIX = "portal_";
|
|
9
|
+
/** Everything the generated portal wiring needs, defaults applied. */
|
|
10
|
+
export interface ResolvedPortal {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
basePath: string;
|
|
13
|
+
cookiePrefix: string;
|
|
14
|
+
tablePrefix: string;
|
|
15
|
+
roles: string[];
|
|
16
|
+
/** First role in `roles` — what a newly created account gets. */
|
|
17
|
+
defaultRole: string;
|
|
18
|
+
routes: PortalRoute[];
|
|
19
|
+
home: Record<string, string>;
|
|
20
|
+
signUp: boolean;
|
|
21
|
+
gated: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Resolve the portal block, or null when the project has no portal. */
|
|
24
|
+
export declare function astroidPortal(config: AstroidConfig): ResolvedPortal | null;
|
|
25
|
+
/** The guard config for a project, ready to hand to `portalGuard`. */
|
|
26
|
+
export declare function astroidPortalGuardConfig(config: AstroidConfig): PortalGuardConfig | null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Portal defaults derived from the project config — the single place that knows
|
|
4
|
+
// the portal's mount, cookie prefix, table prefix, and guard table.
|
|
5
|
+
//
|
|
6
|
+
// The isolation constants are fixed rather than configurable, and that's the
|
|
7
|
+
// point: the studio instance MUST keep Better Auth's defaults (`/api/auth`, the
|
|
8
|
+
// unprefixed tables) because the Louise editor client hardcodes them, so the
|
|
9
|
+
// portal is the one that moves. Leaving that to a project invites the one
|
|
10
|
+
// mistake that matters — two instances sharing a cookie prefix, where signing
|
|
11
|
+
// into one silently signs you out of the other, intermittently, in production.
|
|
12
|
+
import { ASTROID_PORTAL_BASE_PATH } from "../security/rate-rules.js";
|
|
13
|
+
/** Cookie prefix for the portal instance. Distinct from the studio's default
|
|
14
|
+
* (`better-auth`) so the two sessions can coexist on one origin. */
|
|
15
|
+
export const ASTROID_PORTAL_COOKIE_PREFIX = "portal";
|
|
16
|
+
/** Table-name prefix for the portal's Better Auth tables — `portal_user`,
|
|
17
|
+
* `portal_session`, … The studio owns the unprefixed names. */
|
|
18
|
+
export const ASTROID_PORTAL_TABLE_PREFIX = "portal_";
|
|
19
|
+
/** Default guard table: the account area, for any signed-in portal user. */
|
|
20
|
+
const DEFAULT_ROUTES = [{ prefix: "/portal" }, { prefix: "/api/portal" }];
|
|
21
|
+
/** Resolve the portal block, or null when the project has no portal. */
|
|
22
|
+
export function astroidPortal(config) {
|
|
23
|
+
const portal = config.portal;
|
|
24
|
+
if (!portal?.enabled)
|
|
25
|
+
return null;
|
|
26
|
+
const roles = portal.roles?.length ? portal.roles : ["customer"];
|
|
27
|
+
return {
|
|
28
|
+
enabled: true,
|
|
29
|
+
basePath: ASTROID_PORTAL_BASE_PATH,
|
|
30
|
+
cookiePrefix: ASTROID_PORTAL_COOKIE_PREFIX,
|
|
31
|
+
tablePrefix: ASTROID_PORTAL_TABLE_PREFIX,
|
|
32
|
+
roles,
|
|
33
|
+
defaultRole: roles[0],
|
|
34
|
+
routes: portal.routes?.length ? portal.routes : DEFAULT_ROUTES,
|
|
35
|
+
home: portal.home ?? {},
|
|
36
|
+
signUp: portal.signUp ?? false,
|
|
37
|
+
gated: portal.gated ?? false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The guard config for a project, ready to hand to `portalGuard`. */
|
|
41
|
+
export function astroidPortalGuardConfig(config) {
|
|
42
|
+
const portal = astroidPortal(config);
|
|
43
|
+
if (!portal)
|
|
44
|
+
return null;
|
|
45
|
+
return {
|
|
46
|
+
routes: portal.routes,
|
|
47
|
+
loginPath: "/login",
|
|
48
|
+
home: (role) => portal.home[role] ?? "/portal",
|
|
49
|
+
};
|
|
50
|
+
}
|