@escape-game-over/atlas 0.1.86 → 0.1.88
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/package.json +4 -3
- package/src/astro/Document.astro +9 -0
- package/src/astro/GoogleMap.astro +20 -1
- package/src/astro/consent-pages.ts +24 -0
- package/src/astro/google-map.ts +20 -5
- package/src/index.ts +3 -0
- package/src/money.ts +40 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escape-game-over/atlas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.88",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
"node": ">=24.0.0"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
|
-
"test": "npm run test:types && npm run test:unit && npm run test:examples",
|
|
41
|
+
"test": "npm run test:types && npm run test:unit && npm run test:astro && npm run test:examples",
|
|
42
42
|
"test:types": "tsc --noEmit && astro check --root checks/astro",
|
|
43
43
|
"test:unit": "vitest run --coverage",
|
|
44
|
+
"test:astro": "vitest run --config vitest.astro.config.ts",
|
|
44
45
|
"test:watch": "vitest",
|
|
45
46
|
"test:examples": "npm run test --workspaces",
|
|
46
47
|
"lint": "biome check .",
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
"@biomejs/biome": "2.6.5",
|
|
62
63
|
"@types/node": "26.6.6",
|
|
63
64
|
"@vitest/coverage-istanbul": "5.1.0",
|
|
64
|
-
"astro": "7.4.
|
|
65
|
+
"astro": "7.4.7",
|
|
65
66
|
"typescript": "6.0.3",
|
|
66
67
|
"vitest": "5.1.0"
|
|
67
68
|
}
|
package/src/astro/Document.astro
CHANGED
|
@@ -27,11 +27,13 @@
|
|
|
27
27
|
* </Document>
|
|
28
28
|
* ```
|
|
29
29
|
*/
|
|
30
|
+
|
|
30
31
|
import { Font } from "astro:assets";
|
|
31
32
|
import type { HTMLAttributes } from "astro/types";
|
|
32
33
|
import type { Fonts } from "../fonts.ts";
|
|
33
34
|
import { iconLinks } from "../meta/index.ts";
|
|
34
35
|
import type { PageMeta } from "../site/index.ts";
|
|
36
|
+
import { recordConsentBanner } from "./consent-pages.ts";
|
|
35
37
|
import MetaTags from "./MetaTags.astro";
|
|
36
38
|
import RememberCampaign from "./RememberCampaign.astro";
|
|
37
39
|
import RememberLocale from "./RememberLocale.astro";
|
|
@@ -62,6 +64,13 @@ const {
|
|
|
62
64
|
rememberLocale = true,
|
|
63
65
|
} = Astro.props;
|
|
64
66
|
|
|
67
|
+
// Before the slots render, so a component in the page can tell: `GoogleMap`
|
|
68
|
+
// refuses to load on sight where nobody can be asked.
|
|
69
|
+
recordConsentBanner(
|
|
70
|
+
Astro.request,
|
|
71
|
+
meta.consentVendors.length > 0 && Astro.slots.has("consent")
|
|
72
|
+
);
|
|
73
|
+
|
|
65
74
|
if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
|
|
66
75
|
throw new Error(
|
|
67
76
|
`${meta.consentVendors.join(", ")} ${meta.consentVendors.length === 1 ? "needs" : "need"} permission, but this page has no consent banner. Pass one to Document: <ConsentBanner slot="consent" …>.`
|
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* A Google map of `places`, loaded only when asked for. Until then the element
|
|
4
4
|
* shows its children: a picture and a button, say. With `load="click"` (the
|
|
5
|
-
* default) a click anywhere on them loads it
|
|
5
|
+
* default) a click anywhere on them loads it, and that click is the reader
|
|
6
|
+
* asking for Google, so say so on the button. With `"visible"`, scrolling near
|
|
7
|
+
* loads it, but only once the reader has granted marketing consent: Google
|
|
8
|
+
* sets its cookies either way, and nobody asked for the map. A page using
|
|
9
|
+
* `"visible"` without a consent banner fails the build.
|
|
10
|
+
*
|
|
11
|
+
* The check trusts that a filled `consent` slot shows a banner. Astro counts the
|
|
12
|
+
* slot as filled whenever an element names it, so one that renders nothing —
|
|
13
|
+
* `{shown && <div slot="consent">}`, an empty element, a component of your own
|
|
14
|
+
* that renders nothing — passes it with no banner on the page. Put the banner
|
|
15
|
+
* in the slot unconditionally: `ConsentBanner` already renders nothing on a
|
|
16
|
+
* site that needs no permission. Until a reader grants marketing, the map's
|
|
17
|
+
* children stay, so they should read sensibly on their own.
|
|
6
18
|
*
|
|
7
19
|
* `mapId` is required: markers are `AdvancedMarkerElement`, which needs one,
|
|
8
20
|
* and its colours are set on the Map ID in the Cloud console. `colorScheme`
|
|
@@ -26,6 +38,7 @@ import type { HTMLAttributes } from "astro/types";
|
|
|
26
38
|
import { type MapPlace, mapFrame } from "../map.ts";
|
|
27
39
|
import type { NonEmpty } from "../types.ts";
|
|
28
40
|
import AtlasElement from "./AtlasElement.astro";
|
|
41
|
+
import { showsConsentBanner } from "./consent-pages.ts";
|
|
29
42
|
import { type GoogleMapConfig, googleMap } from "./google-map.ts";
|
|
30
43
|
|
|
31
44
|
type Props = HTMLAttributes<"div"> & {
|
|
@@ -45,6 +58,12 @@ const {
|
|
|
45
58
|
...attrs
|
|
46
59
|
} = Astro.props;
|
|
47
60
|
|
|
61
|
+
if (load === "visible" && showsConsentBanner(Astro.request) !== true) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`${Astro.url.pathname}: GoogleMap with load="visible" waits for marketing consent, but this page shows no consent banner to ask for it. Use load="click", where the reader's click is the asking, or give the page a banner: <ConsentBanner slot="consent" …> in Document, with a vendor that needs it.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
48
67
|
const config: GoogleMapConfig = {
|
|
49
68
|
apiKey,
|
|
50
69
|
mapId,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pages of a build show a consent banner, for a component that must not
|
|
3
|
+
* render without one.
|
|
4
|
+
*
|
|
5
|
+
* `Document` records it from the page's `meta` before it renders its slots, so
|
|
6
|
+
* anything in the page's body can ask. Keyed by the page's request: each page
|
|
7
|
+
* of a static build renders with its own, and a `WeakMap` lets them go.
|
|
8
|
+
*
|
|
9
|
+
* Server-only, like `Document`.
|
|
10
|
+
*/
|
|
11
|
+
const pages = new WeakMap<Request, boolean>();
|
|
12
|
+
|
|
13
|
+
/** What `Document` records: whether this page shows a consent banner. */
|
|
14
|
+
export function recordConsentBanner(request: Request, shown: boolean): void {
|
|
15
|
+
pages.set(request, shown);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether this page shows a consent banner. `undefined` when no `Document` has
|
|
20
|
+
* rendered for it, which a caller should treat as no.
|
|
21
|
+
*/
|
|
22
|
+
export function showsConsentBanner(request: Request): boolean | undefined {
|
|
23
|
+
return pages.get(request);
|
|
24
|
+
}
|
package/src/astro/google-map.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference types="google.maps" />
|
|
2
2
|
import type { MapFrame } from "../map.ts";
|
|
3
3
|
import { appendScript } from "./append-script.ts";
|
|
4
|
+
import { whenGranted } from "./consent-gate.ts";
|
|
4
5
|
import { reportDevError } from "./dev-log.ts";
|
|
5
6
|
import { element } from "./element.ts";
|
|
6
7
|
import { triggered } from "./load-script.ts";
|
|
@@ -122,10 +123,24 @@ export const googleMap = element("atlas-google-map", ({ root, signal }) => {
|
|
|
122
123
|
draw(root, config).catch((error) => reportDevError("GoogleMap", error));
|
|
123
124
|
};
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
if (config.load === "click") {
|
|
127
|
+
triggered({ on: "click", element: root }, signal).then(show);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// On sight, but never before marketing is granted: the build makes sure
|
|
132
|
+
// there is a banner to grant it. Once drawn, a withdrawn grant leaves the
|
|
133
|
+
// map be — Google has already set what it sets.
|
|
134
|
+
let asked = false;
|
|
135
|
+
whenGranted(
|
|
136
|
+
"marketing",
|
|
137
|
+
{
|
|
138
|
+
grant: () => {
|
|
139
|
+
if (asked) return;
|
|
140
|
+
asked = true;
|
|
141
|
+
triggered({ on: "visible", element: root }, signal).then(show);
|
|
142
|
+
},
|
|
143
|
+
},
|
|
129
144
|
signal
|
|
130
|
-
)
|
|
145
|
+
);
|
|
131
146
|
});
|
package/src/index.ts
CHANGED
|
@@ -215,14 +215,17 @@ export {
|
|
|
215
215
|
export {
|
|
216
216
|
type AudiencePrice,
|
|
217
217
|
type AudienceTier,
|
|
218
|
+
amountAt,
|
|
218
219
|
assertAudiences,
|
|
219
220
|
assertPriceTiers,
|
|
220
221
|
type CurrencyCode,
|
|
222
|
+
formatPrice,
|
|
221
223
|
formatQuantities,
|
|
222
224
|
type Price,
|
|
223
225
|
type PriceTier,
|
|
224
226
|
type Quantity,
|
|
225
227
|
type QuantityRange,
|
|
228
|
+
quantitiesOf,
|
|
226
229
|
type TieredPrice,
|
|
227
230
|
tierRange,
|
|
228
231
|
} from "./money.ts";
|
package/src/money.ts
CHANGED
|
@@ -480,3 +480,43 @@ export function assertPriceTiers(price: TieredPrice, at: string): void {
|
|
|
480
480
|
);
|
|
481
481
|
}
|
|
482
482
|
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* What one unit costs at `quantity`: the amount of the band that covers it, or
|
|
486
|
+
* `undefined` where the table has no band for it — a group too small, too
|
|
487
|
+
* large, or in a hole between bands.
|
|
488
|
+
*
|
|
489
|
+
* The first band that covers it wins. `assertPriceTiers` refuses overlapping
|
|
490
|
+
* bands, so in a table that passed it there is only ever one.
|
|
491
|
+
*/
|
|
492
|
+
export function amountAt(
|
|
493
|
+
price: TieredPrice,
|
|
494
|
+
quantity: number
|
|
495
|
+
): number | undefined {
|
|
496
|
+
for (const tier of price.tiers) {
|
|
497
|
+
const band = tierRange(tier);
|
|
498
|
+
if (quantity >= band.min && quantity <= band.max) {
|
|
499
|
+
return tier.amountPerUnit;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* A price as a reader in `locale` writes it: the currency's symbol and that
|
|
507
|
+
* language's separators, and no decimals on a whole amount — "£25", not
|
|
508
|
+
* "£25.00", while "€12.50" keeps its cents.
|
|
509
|
+
*
|
|
510
|
+
* Whole amounts drop the decimals because that is how a price list reads; a
|
|
511
|
+
* table of "25.00, 22.00, 20.00" says nothing "25, 22, 20" does not. An amount
|
|
512
|
+
* with cents shows two, so a price is never rounded on the way to the page.
|
|
513
|
+
*/
|
|
514
|
+
export function formatPrice(price: Price, locale: string): string {
|
|
515
|
+
const whole = Number.isInteger(price.amount);
|
|
516
|
+
return new Intl.NumberFormat(locale, {
|
|
517
|
+
style: "currency",
|
|
518
|
+
currency: price.currency,
|
|
519
|
+
minimumFractionDigits: whole ? 0 : 2,
|
|
520
|
+
maximumFractionDigits: whole ? 0 : 2,
|
|
521
|
+
}).format(price.amount);
|
|
522
|
+
}
|