@escape-game-over/atlas 0.1.72 → 0.1.74

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -0,0 +1,669 @@
1
+ import type { CountryCode } from "./countries.ts";
2
+
3
+ /** A part of `PostalAddress` an address layout can print. */
4
+ export type AddressField = "street" | "locality" | "region" | "postalCode";
5
+
6
+ /**
7
+ * One piece of a line: a field, or text the country always prints.
8
+ *
9
+ * A field brings its own punctuation, so a missing field takes it with it and
10
+ * nothing is left to clean up:
11
+ *
12
+ * - `separator` sits between this field and whatever the line already holds,
13
+ * and is dropped when the field opens the line: `, ` before a US state.
14
+ * - `prefix` belongs to the value itself and goes wherever it goes: the `CH-`
15
+ * of a Swiss postal code.
16
+ */
17
+ export type AddressPart =
18
+ | {
19
+ readonly field: AddressField;
20
+ readonly separator?: string;
21
+ readonly prefix?: string;
22
+ }
23
+ | { readonly text: string };
24
+
25
+ /** A country's address, line by line. */
26
+ export type AddressLayout = readonly (readonly AddressPart[])[];
27
+
28
+ /**
29
+ * How a country lays out an address.
30
+ *
31
+ * Converted once from Google's address metadata, the dataset behind
32
+ * libaddressinput (Apache License 2.0), fetched 2026-10-02 one country at a
33
+ * time: <https://chromium-i18n.appspot.com/ssl-address/data/DE>, the `fmt`
34
+ * field. The bare `/data/` path answers `{}`. Google writes each layout as a
35
+ * format string, `%A%n%C, %S %Z`; this is the same layout as data, so nothing
36
+ * parses a string at run time.
37
+ *
38
+ * The conversion:
39
+ * - `%A` street
40
+ * - `%C` locality
41
+ * - `%S` region
42
+ * - `%Z` postal code
43
+ * became fields; text before a line's first field became that field's
44
+ * `prefix`, and text between two fields the second one's `separator`; a line of
45
+ * text alone became a `text` part. The recipient lines (`%N`, `%O`) and the
46
+ * fields `PostalAddress` does not carry — district, sorting code, India's
47
+ * extra lines — were dropped, with the punctuation that belonged to them.
48
+ *
49
+ * Countries that share a layout share a branch, in order of their first code.
50
+ * To update one, take its format from that address again and convert it the
51
+ * same way. A country listed twice is caught by Biome's `noDuplicateCase`.
52
+ */
53
+ export function addressLayout(country: CountryCode): AddressLayout {
54
+ switch (country) {
55
+ case "AD":
56
+ case "AT":
57
+ case "BA":
58
+ case "BE":
59
+ case "BG":
60
+ case "BL":
61
+ case "CY":
62
+ case "CZ":
63
+ case "DE":
64
+ case "DK":
65
+ case "DO":
66
+ case "DZ":
67
+ case "EH":
68
+ case "ET":
69
+ case "FR":
70
+ case "GE":
71
+ case "GF":
72
+ case "GL":
73
+ case "GP":
74
+ case "GR":
75
+ case "GW":
76
+ case "IS":
77
+ case "KG":
78
+ case "KW":
79
+ case "LA":
80
+ case "LR":
81
+ case "MA":
82
+ case "ME":
83
+ case "MF":
84
+ case "MG":
85
+ case "MK":
86
+ case "MQ":
87
+ case "NC":
88
+ case "NE":
89
+ case "NL":
90
+ case "NO":
91
+ case "PL":
92
+ case "PM":
93
+ case "PT":
94
+ case "PY":
95
+ case "RE":
96
+ case "RS":
97
+ case "SJ":
98
+ case "SK":
99
+ case "SM":
100
+ case "SN":
101
+ case "TJ":
102
+ case "TM":
103
+ case "TN":
104
+ case "TZ":
105
+ case "VA":
106
+ case "WF":
107
+ case "XK":
108
+ case "YT":
109
+ case "ZM":
110
+ return [
111
+ [{ field: "street" }],
112
+ [
113
+ { field: "postalCode" },
114
+ { field: "locality", separator: " " },
115
+ ],
116
+ ];
117
+ case "AE":
118
+ case "NR":
119
+ return [[{ field: "street" }], [{ field: "region" }]];
120
+ case "AF":
121
+ case "AI":
122
+ case "FK":
123
+ case "GB":
124
+ case "GS":
125
+ case "IM":
126
+ case "IO":
127
+ case "KE":
128
+ case "LK":
129
+ case "NA":
130
+ case "PN":
131
+ case "SD":
132
+ case "SH":
133
+ case "SZ":
134
+ case "TC":
135
+ case "TT":
136
+ case "VG":
137
+ case "ZA":
138
+ return [
139
+ [{ field: "street" }],
140
+ [{ field: "locality" }],
141
+ [{ field: "postalCode" }],
142
+ ];
143
+ case "AL":
144
+ case "EC":
145
+ case "MU":
146
+ case "OM":
147
+ return [
148
+ [{ field: "street" }],
149
+ [{ field: "postalCode" }],
150
+ [{ field: "locality" }],
151
+ ];
152
+ case "AM":
153
+ return [
154
+ [{ field: "street" }],
155
+ [{ field: "postalCode" }],
156
+ [{ field: "locality" }],
157
+ [{ field: "region" }],
158
+ ];
159
+ case "AR":
160
+ case "CL":
161
+ case "CV":
162
+ case "MY":
163
+ case "UZ":
164
+ return [
165
+ [{ field: "street" }],
166
+ [
167
+ { field: "postalCode" },
168
+ { field: "locality", separator: " " },
169
+ ],
170
+ [{ field: "region" }],
171
+ ];
172
+ case "AS":
173
+ case "AU":
174
+ case "CA":
175
+ case "CC":
176
+ case "CX":
177
+ case "FM":
178
+ case "HM":
179
+ case "MH":
180
+ case "MP":
181
+ case "NF":
182
+ case "PW":
183
+ case "UM":
184
+ case "VI":
185
+ return [
186
+ [{ field: "street" }],
187
+ [
188
+ { field: "locality" },
189
+ { field: "region", separator: " " },
190
+ { field: "postalCode", separator: " " },
191
+ ],
192
+ ];
193
+ case "AX":
194
+ return [
195
+ [{ field: "street" }],
196
+ [
197
+ { field: "postalCode", prefix: "AX-" },
198
+ { field: "locality", separator: " " },
199
+ ],
200
+ [{ text: "ÅLAND" }],
201
+ ];
202
+ case "AZ":
203
+ return [
204
+ [{ field: "street" }],
205
+ [
206
+ { field: "postalCode", prefix: "AZ " },
207
+ { field: "locality", separator: " " },
208
+ ],
209
+ ];
210
+ case "BB":
211
+ case "SO":
212
+ case "US":
213
+ return [
214
+ [{ field: "street" }],
215
+ [
216
+ { field: "locality" },
217
+ { field: "region", separator: ", " },
218
+ { field: "postalCode", separator: " " },
219
+ ],
220
+ ];
221
+ case "BD":
222
+ return [
223
+ [{ field: "street" }],
224
+ [
225
+ { field: "locality" },
226
+ { field: "postalCode", separator: " - " },
227
+ ],
228
+ ];
229
+ case "BH":
230
+ case "BM":
231
+ case "BN":
232
+ case "BT":
233
+ case "GU":
234
+ case "IL":
235
+ case "JO":
236
+ case "KH":
237
+ case "LB":
238
+ case "LS":
239
+ case "MT":
240
+ case "MV":
241
+ case "NP":
242
+ case "NZ":
243
+ case "SA":
244
+ case "VC":
245
+ return [
246
+ [{ field: "street" }],
247
+ [
248
+ { field: "locality" },
249
+ { field: "postalCode", separator: " " },
250
+ ],
251
+ ];
252
+ case "BR":
253
+ return [
254
+ [{ field: "street" }],
255
+ [{ field: "locality" }, { field: "region", separator: "-" }],
256
+ [{ field: "postalCode" }],
257
+ ];
258
+ case "BS":
259
+ case "KN":
260
+ return [
261
+ [{ field: "street" }],
262
+ [{ field: "locality" }, { field: "region", separator: ", " }],
263
+ ];
264
+ case "BY":
265
+ return [
266
+ [{ field: "street" }],
267
+ [
268
+ { field: "postalCode" },
269
+ { field: "locality", separator: ", " },
270
+ ],
271
+ [{ field: "region" }],
272
+ ];
273
+ case "CH":
274
+ return [
275
+ [{ field: "street" }],
276
+ [
277
+ { field: "postalCode", prefix: "CH-" },
278
+ { field: "locality", separator: " " },
279
+ ],
280
+ ];
281
+ case "CI":
282
+ return [
283
+ [{ field: "street" }, { field: "locality", separator: " " }],
284
+ ];
285
+ // No space between region and town, as both write them.
286
+ case "CN":
287
+ case "TW":
288
+ return [
289
+ [{ field: "postalCode" }],
290
+ [{ field: "region" }, { field: "locality" }],
291
+ [{ field: "street" }],
292
+ ];
293
+ case "CO":
294
+ return [
295
+ [{ field: "street" }],
296
+ [
297
+ { field: "locality" },
298
+ { field: "region", separator: ", " },
299
+ { field: "postalCode", separator: ", " },
300
+ ],
301
+ ];
302
+ case "CR":
303
+ return [
304
+ [{ field: "street" }],
305
+ [{ field: "region" }, { field: "locality", separator: ", " }],
306
+ [{ field: "postalCode" }],
307
+ ];
308
+ case "CU":
309
+ return [
310
+ [{ field: "street" }],
311
+ [{ field: "locality" }, { field: "region", separator: " " }],
312
+ [{ field: "postalCode" }],
313
+ ];
314
+ case "EE":
315
+ case "ES":
316
+ case "IT":
317
+ case "PF":
318
+ case "UY":
319
+ return [
320
+ [{ field: "street" }],
321
+ [
322
+ { field: "postalCode" },
323
+ { field: "locality", separator: " " },
324
+ { field: "region", separator: " " },
325
+ ],
326
+ ];
327
+ case "EG":
328
+ case "IE":
329
+ case "RU":
330
+ case "UA":
331
+ return [
332
+ [{ field: "street" }],
333
+ [{ field: "locality" }],
334
+ [{ field: "region" }],
335
+ [{ field: "postalCode" }],
336
+ ];
337
+ case "FI":
338
+ return [
339
+ [{ field: "street" }],
340
+ [
341
+ { field: "postalCode", prefix: "FI-" },
342
+ { field: "locality", separator: " " },
343
+ ],
344
+ ];
345
+ case "FO":
346
+ return [
347
+ [{ field: "street" }],
348
+ [
349
+ { field: "postalCode", prefix: "FO" },
350
+ { field: "locality", separator: " " },
351
+ ],
352
+ ];
353
+ case "GG":
354
+ return [
355
+ [{ field: "street" }],
356
+ [{ field: "locality" }],
357
+ [{ text: "GUERNSEY" }],
358
+ [{ field: "postalCode" }],
359
+ ];
360
+ case "GI":
361
+ return [
362
+ [{ field: "street" }],
363
+ [{ text: "GIBRALTAR" }],
364
+ [{ field: "postalCode" }],
365
+ ];
366
+ case "GN":
367
+ return [
368
+ [
369
+ { field: "postalCode" },
370
+ { field: "street", separator: " " },
371
+ { field: "locality", separator: " " },
372
+ ],
373
+ ];
374
+ case "GT":
375
+ return [
376
+ [{ field: "street" }],
377
+ [
378
+ { field: "postalCode" },
379
+ { field: "locality", separator: "- " },
380
+ ],
381
+ ];
382
+ case "HK":
383
+ return [
384
+ [{ field: "region" }],
385
+ [{ field: "locality" }],
386
+ [{ field: "street" }],
387
+ ];
388
+ case "HN":
389
+ case "IQ":
390
+ return [
391
+ [{ field: "street" }],
392
+ [{ field: "locality" }, { field: "region", separator: ", " }],
393
+ [{ field: "postalCode" }],
394
+ ];
395
+ case "HR":
396
+ return [
397
+ [{ field: "street" }],
398
+ [
399
+ { field: "postalCode", prefix: "HR-" },
400
+ { field: "locality", separator: " " },
401
+ ],
402
+ ];
403
+ case "HT":
404
+ return [
405
+ [{ field: "street" }],
406
+ [
407
+ { field: "postalCode", prefix: "HT" },
408
+ { field: "locality", separator: " " },
409
+ ],
410
+ ];
411
+ case "HU":
412
+ return [
413
+ [{ field: "locality" }],
414
+ [{ field: "street" }],
415
+ [{ field: "postalCode" }],
416
+ ];
417
+ case "ID":
418
+ case "MN":
419
+ case "TH":
420
+ case "VN":
421
+ return [
422
+ [{ field: "street" }],
423
+ [{ field: "locality" }],
424
+ [{ field: "region" }, { field: "postalCode", separator: " " }],
425
+ ];
426
+ case "IN":
427
+ case "NG":
428
+ case "PE":
429
+ return [
430
+ [{ field: "street" }],
431
+ [
432
+ { field: "locality" },
433
+ { field: "postalCode", separator: " " },
434
+ ],
435
+ [{ field: "region" }],
436
+ ];
437
+ case "IR":
438
+ return [
439
+ [{ field: "region" }],
440
+ [{ field: "locality" }],
441
+ [{ field: "street" }],
442
+ [{ field: "postalCode" }],
443
+ ];
444
+ case "JE":
445
+ return [
446
+ [{ field: "street" }],
447
+ [{ field: "locality" }],
448
+ [{ text: "JERSEY" }],
449
+ [{ field: "postalCode" }],
450
+ ];
451
+ case "JM":
452
+ case "PA":
453
+ case "SC":
454
+ case "SR":
455
+ case "TV":
456
+ return [
457
+ [{ field: "street" }],
458
+ [{ field: "locality" }],
459
+ [{ field: "region" }],
460
+ ];
461
+ case "JP":
462
+ return [
463
+ [{ field: "postalCode", prefix: "〒" }],
464
+ [{ field: "region" }],
465
+ [{ field: "street" }],
466
+ ];
467
+ case "KI":
468
+ return [
469
+ [{ field: "street" }],
470
+ [{ field: "region" }],
471
+ [{ field: "locality" }],
472
+ ];
473
+ case "KP":
474
+ case "KZ":
475
+ return [
476
+ [{ field: "postalCode" }],
477
+ [{ field: "region" }],
478
+ [{ field: "locality" }],
479
+ [{ field: "street" }],
480
+ ];
481
+ case "KR":
482
+ return [
483
+ [{ field: "region" }, { field: "locality", separator: " " }],
484
+ [{ field: "street" }],
485
+ [{ field: "postalCode" }],
486
+ ];
487
+ case "KY":
488
+ return [
489
+ [{ field: "street" }],
490
+ [{ field: "region" }, { field: "postalCode", separator: " " }],
491
+ ];
492
+ case "LI":
493
+ return [
494
+ [{ field: "street" }],
495
+ [
496
+ { field: "postalCode", prefix: "FL-" },
497
+ { field: "locality", separator: " " },
498
+ ],
499
+ ];
500
+ case "LT":
501
+ return [
502
+ [{ field: "street" }],
503
+ [
504
+ { field: "postalCode", prefix: "LT-" },
505
+ { field: "locality", separator: " " },
506
+ { field: "region", separator: " " },
507
+ ],
508
+ ];
509
+ case "LU":
510
+ return [
511
+ [{ field: "street" }],
512
+ [
513
+ { field: "postalCode", prefix: "L-" },
514
+ { field: "locality", separator: " " },
515
+ ],
516
+ ];
517
+ case "LV":
518
+ return [
519
+ [{ field: "street" }],
520
+ [{ field: "region" }],
521
+ [
522
+ { field: "locality" },
523
+ { field: "postalCode", separator: ", " },
524
+ ],
525
+ ];
526
+ case "MC":
527
+ return [
528
+ [{ field: "street" }],
529
+ [
530
+ { field: "postalCode", prefix: "MC-" },
531
+ { field: "locality", separator: " " },
532
+ ],
533
+ ];
534
+ case "MD":
535
+ return [
536
+ [{ field: "street" }],
537
+ [
538
+ { field: "postalCode", prefix: "MD-" },
539
+ { field: "locality", separator: " " },
540
+ ],
541
+ ];
542
+ case "MM":
543
+ return [
544
+ [{ field: "street" }],
545
+ [
546
+ { field: "locality" },
547
+ { field: "postalCode", separator: ", " },
548
+ ],
549
+ ];
550
+ case "MO":
551
+ return [[{ field: "street" }]];
552
+ case "MX":
553
+ return [
554
+ [{ field: "street" }],
555
+ [
556
+ { field: "postalCode" },
557
+ { field: "locality", separator: " " },
558
+ { field: "region", separator: ", " },
559
+ ],
560
+ ];
561
+ case "MZ":
562
+ return [
563
+ [{ field: "street" }],
564
+ [
565
+ { field: "postalCode" },
566
+ { field: "locality", separator: " " },
567
+ { field: "region" },
568
+ ],
569
+ ];
570
+ case "NI":
571
+ return [
572
+ [{ field: "street" }],
573
+ [{ field: "postalCode" }],
574
+ [{ field: "locality" }, { field: "region", separator: ", " }],
575
+ ];
576
+ case "PG":
577
+ return [
578
+ [{ field: "street" }],
579
+ [
580
+ { field: "locality" },
581
+ { field: "postalCode", separator: " " },
582
+ { field: "region", separator: " " },
583
+ ],
584
+ ];
585
+ case "PH":
586
+ return [
587
+ [{ field: "street" }],
588
+ [{ field: "locality" }],
589
+ [{ field: "postalCode" }, { field: "region", separator: " " }],
590
+ ];
591
+ case "PK":
592
+ return [
593
+ [{ field: "street" }],
594
+ [
595
+ { field: "locality" },
596
+ { field: "postalCode", separator: "-" },
597
+ ],
598
+ ];
599
+ case "PR":
600
+ return [
601
+ [{ field: "street" }],
602
+ [
603
+ { field: "locality" },
604
+ { field: "postalCode", separator: " PR " },
605
+ ],
606
+ ];
607
+ case "RO":
608
+ return [
609
+ [{ field: "street" }],
610
+ [
611
+ { field: "postalCode" },
612
+ { field: "region", separator: " " },
613
+ { field: "locality", separator: " " },
614
+ ],
615
+ ];
616
+ case "SE":
617
+ return [
618
+ [{ field: "street" }],
619
+ [
620
+ { field: "postalCode", prefix: "SE-" },
621
+ { field: "locality", separator: " " },
622
+ ],
623
+ ];
624
+ case "SG":
625
+ return [
626
+ [{ field: "street" }],
627
+ [{ field: "postalCode", prefix: "SINGAPORE " }],
628
+ ];
629
+ case "SI":
630
+ return [
631
+ [{ field: "street" }],
632
+ [
633
+ { field: "postalCode", prefix: "SI-" },
634
+ { field: "locality", separator: " " },
635
+ ],
636
+ ];
637
+ case "SV":
638
+ return [
639
+ [{ field: "street" }],
640
+ [
641
+ { field: "postalCode" },
642
+ { field: "locality", separator: "-" },
643
+ ],
644
+ [{ field: "region" }],
645
+ ];
646
+ case "TR":
647
+ return [
648
+ [{ field: "street" }],
649
+ [
650
+ { field: "postalCode" },
651
+ { field: "locality", separator: " " },
652
+ { field: "region", separator: "/" },
653
+ ],
654
+ ];
655
+ case "VE":
656
+ return [
657
+ [{ field: "street" }],
658
+ [
659
+ { field: "locality" },
660
+ { field: "postalCode", separator: " " },
661
+ { field: "region", separator: ", " },
662
+ ],
663
+ ];
664
+ // Google's default: BF and MW, and every country it gives no layout of
665
+ // its own.
666
+ default:
667
+ return [[{ field: "street" }], [{ field: "locality" }]];
668
+ }
669
+ }
package/src/contact.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { addressLayout } from "./address-formats.ts";
1
2
  import type { CountryCode } from "./countries.ts";
2
3
  import type { Digit } from "./types.ts";
3
4
  import { warn } from "./warn.ts";
@@ -300,8 +301,12 @@ export interface PostalAddress {
300
301
  readonly street: string;
301
302
  /** Town or city, in its own language: `"Roma"`. */
302
303
  readonly locality: string;
303
- /** Postal or ZIP code, as written locally. */
304
- readonly postalCode: string;
304
+ /**
305
+ * Postal or ZIP code, as written locally. None where the country has no
306
+ * postal codes, as in the UAE: left out rather than sent empty, like
307
+ * `region`.
308
+ */
309
+ readonly postalCode?: string;
305
310
  /**
306
311
  * The country, as its two-letter code: `"IT"`, not `"Italy"`.
307
312
  *
@@ -313,3 +318,49 @@ export interface PostalAddress {
313
318
  /** State, province or region, where the country uses one. */
314
319
  readonly region?: string;
315
320
  }
321
+
322
+ /**
323
+ * The record written out in its country's postal order, one string per line:
324
+ * `["Via Nazionale 100", "00184 Roma"]`, `["1 Example Ave", "Austin, TX
325
+ * 78701"]`.
326
+ *
327
+ * Still the canonical address — `Roma`, on every page — so it suits a list of
328
+ * venues or an envelope, not a sentence; a page that phrases the address for
329
+ * its reader writes it in the catalog, like the rest of its copy.
330
+ *
331
+ * The order is Google's address metadata, converted once into
332
+ * `addressLayout`, not a rule per country written here. A field the address does not have prints
333
+ * nothing, and its punctuation goes with it, because each field carries its
334
+ * own: no postal code, no `CH-`. Letter case is the caller's: some countries
335
+ * want the town in capitals on an envelope, which is not how a page should
336
+ * print it.
337
+ */
338
+ export function addressLines(address: PostalAddress): readonly string[] {
339
+ const lines: string[] = [];
340
+ for (const parts of addressLayout(address.country)) {
341
+ let line = "";
342
+ for (const part of parts) {
343
+ if ("text" in part) {
344
+ line += part.text;
345
+ continue;
346
+ }
347
+ const value = address[part.field]?.trim();
348
+ if (!value) continue;
349
+ if (line !== "") line += part.separator ?? "";
350
+ line += (part.prefix ?? "") + value;
351
+ }
352
+ if (line !== "") lines.push(line);
353
+ }
354
+ return lines;
355
+ }
356
+
357
+ /**
358
+ * `addressLines` on one line, for where there is room for only one: a venue
359
+ * card, an `llms.txt` entry.
360
+ */
361
+ export function addressLine(
362
+ address: PostalAddress,
363
+ separator: string = ", "
364
+ ): string {
365
+ return addressLines(address).join(separator);
366
+ }
package/src/index.ts CHANGED
@@ -51,6 +51,8 @@ export type {
51
51
  SiteConfigShape,
52
52
  } from "./config.ts";
53
53
  export {
54
+ addressLine,
55
+ addressLines,
54
56
  assertCoordinates,
55
57
  type CallingCode,
56
58
  type Coordinates,
@@ -175,7 +177,7 @@ export {
175
177
  type LlmsItem,
176
178
  type LlmsSection,
177
179
  } from "./llms.ts";
178
- export type { MapPlace } from "./map.ts";
180
+ export { googleMapsUrl, type MapPlace, type MapsTarget } from "./map.ts";
179
181
  export {
180
182
  type ArticleContent,
181
183
  buildMeta,
@@ -37,7 +37,9 @@ export function postalAddress(address: PostalAddress): JsonLdNode {
37
37
  "@type": "PostalAddress",
38
38
  streetAddress: address.street,
39
39
  addressLocality: address.locality,
40
- postalCode: address.postalCode,
40
+ ...(address.postalCode === undefined
41
+ ? {}
42
+ : { postalCode: address.postalCode }),
41
43
  addressCountry: address.country,
42
44
  ...(address.region === undefined
43
45
  ? {}
package/src/map.ts CHANGED
@@ -29,6 +29,61 @@ export type MapFrame =
29
29
  readonly east: number;
30
30
  };
31
31
 
32
+ /** What a Google Maps link opens on. */
33
+ export interface MapsTarget {
34
+ /**
35
+ * The place's name, or where it is.
36
+ *
37
+ * Required even with a `placeId`: Google's link format takes the id as a
38
+ * refinement of a search rather than instead of one, and falls back to the
39
+ * search when it cannot use the id. A name searches better than a pair of
40
+ * numbers, so pass one where there is one.
41
+ */
42
+ readonly query: string | Coordinates;
43
+ /**
44
+ * Google's Place ID (`ChIJ…`): the listing itself, with its reviews and
45
+ * directions, rather than whatever a search turns up first. A short
46
+ * `maps.app.goo.gl` link points at the same listing, but cannot be built,
47
+ * checked or told apart from a link to the mall next door.
48
+ */
49
+ readonly placeId?: string;
50
+ }
51
+
52
+ /**
53
+ * A link that opens Google Maps on a place: its listing when it has a Place ID,
54
+ * otherwise a search.
55
+ *
56
+ * Google's documented, key-free format, so it works without the Maps
57
+ * JavaScript API and outlives any short link.
58
+ *
59
+ * @see https://developers.google.com/maps/documentation/urls/get-started#search-action
60
+ */
61
+ export function googleMapsUrl(target: MapsTarget): HttpsUrl {
62
+ let query: string;
63
+ if (typeof target.query === "string") {
64
+ query = target.query.trim();
65
+ if (query === "") {
66
+ throw new Error(
67
+ "googleMapsUrl: an empty query searches for nothing."
68
+ );
69
+ }
70
+ } else {
71
+ assertCoordinates(target.query, "googleMapsUrl");
72
+ query = `${target.query.latitude},${target.query.longitude}`;
73
+ }
74
+
75
+ const params = new URLSearchParams({ api: "1", query });
76
+ if (target.placeId !== undefined) {
77
+ if (target.placeId.trim() === "") {
78
+ throw new Error(
79
+ `googleMapsUrl: "${query}" has an empty Place ID; leave it out to search instead.`
80
+ );
81
+ }
82
+ params.set("query_place_id", target.placeId);
83
+ }
84
+ return `https://www.google.com/maps/search/?${params.toString()}`;
85
+ }
86
+
32
87
  /** Street level: close enough to find the door. */
33
88
  const POINT_ZOOM = 15;
34
89