@escape-game-over/atlas 0.1.33 → 0.1.35

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.33",
3
+ "version": "0.1.35",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -54,7 +54,7 @@
54
54
  "@biomejs/biome": "2.5.14",
55
55
  "@types/node": "26.6.2",
56
56
  "@vitest/coverage-istanbul": "5.0.1",
57
- "astro": "7.3.4",
57
+ "astro": "7.3.5",
58
58
  "typescript": "6.0.3",
59
59
  "vitest": "5.0.1"
60
60
  }
@@ -1,3 +1,4 @@
1
+ import type { NonEmpty } from "../types.ts";
1
2
  import { warn } from "../warn.ts";
2
3
  import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
3
4
 
@@ -129,7 +130,7 @@ export interface GoogleSettings {
129
130
  * banner that loads slowly. See `DENIED_BY_DEFAULT` for exactly what the
130
131
  * unstated case emits.
131
132
  */
132
- readonly consent?: readonly [ConsentDefaults, ...ConsentDefaults[]];
133
+ readonly consent?: NonEmpty<ConsentDefaults>;
133
134
  }
134
135
 
135
136
  /**
@@ -1,4 +1,5 @@
1
1
  import type { MetaTag } from "../meta/tag.ts";
2
+ import type { NonEmpty } from "../types.ts";
2
3
  import { type HttpsUrl, joinUrl, type UrlPath } from "../url.ts";
3
4
  import { warn } from "../warn.ts";
4
5
  import { preconnect } from "./tags.ts";
@@ -142,7 +143,7 @@ export interface UmamiSettings {
142
143
  * `www.` traffic of every site serving both. Stating it is a moment's
143
144
  * thought; the alternative is a number that reads low forever.
144
145
  */
145
- readonly domains: readonly [string, ...string[]];
146
+ readonly domains: NonEmpty<string>;
146
147
  /**
147
148
  * Where the collected data is *sent*, if not where the script came from.
148
149
  *
package/src/hours.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Digit } from "./types.ts";
1
+ import type { Digit, NonEmpty } from "./types.ts";
2
2
 
3
3
  /**
4
4
  * When a venue is open, as a week rather than a sentence.
@@ -69,7 +69,7 @@ export interface TimeRange {
69
69
  *
70
70
  * Several spans cover a venue that shuts for the afternoon.
71
71
  */
72
- export type DayHours = "closed" | readonly [TimeRange, ...TimeRange[]];
72
+ export type DayHours = "closed" | NonEmpty<TimeRange>;
73
73
 
74
74
  /**
75
75
  * The whole week, every day stated.
@@ -83,7 +83,7 @@ export type WeeklyHours = Readonly<Record<Weekday, DayHours>>;
83
83
  /** Days that share the same hours, ready to be printed or serialised. */
84
84
  export interface HoursGroup {
85
85
  /** Every day in the group, in week order. */
86
- readonly days: readonly [Weekday, ...Weekday[]];
86
+ readonly days: NonEmpty<Weekday>;
87
87
  /**
88
88
  * The same days as consecutive spans: `[["monday", "thursday"],
89
89
  * ["sunday", "sunday"]]` is what a footer renders as "Mon–Thu & Sun".
package/src/index.ts CHANGED
@@ -160,7 +160,8 @@ export {
160
160
  type ArticleContent,
161
161
  buildMeta,
162
162
  buildNotFoundMeta,
163
- META_LENGTH_LIMITS,
163
+ fitDescription,
164
+ fitTitle,
164
165
  type MetaContent,
165
166
  type MetaTag,
166
167
  type OpenGraphType,
@@ -233,7 +234,7 @@ export type {
233
234
  StaticPath,
234
235
  } from "./site/index.ts";
235
236
  export type { Sitemap, SitemapConfig } from "./sitemap.ts";
236
- export type { IsoDate, Letter, StringKeys } from "./types.ts";
237
+ export type { IsoDate, Letter, NonEmpty, StringKeys } from "./types.ts";
237
238
  export {
238
239
  absoluteUrl,
239
240
  type Hash,
@@ -7,6 +7,7 @@ import {
7
7
  } from "../contact.ts";
8
8
  import type { WeeklyHours } from "../hours.ts";
9
9
  import type { ImageAsset } from "../image.ts";
10
+ import type { NonEmpty } from "../types.ts";
10
11
  import { absoluteUrl, type HttpsUrl } from "../url.ts";
11
12
  import { businessId, type OrganizationId } from "./ids.ts";
12
13
  import { alternateName, type JsonLdNode, schemaType } from "./node.ts";
@@ -126,7 +127,7 @@ export interface LocalBusinessInput {
126
127
  * Reference:
127
128
  * <https://developers.google.com/search/docs/appearance/structured-data/local-business>
128
129
  */
129
- readonly type?: string | readonly [string, ...string[]];
130
+ readonly type?: string | NonEmpty<string>;
130
131
  /**
131
132
  * Pages elsewhere that are unmistakably this same venue.
132
133
  *
@@ -5,6 +5,7 @@ import {
5
5
  quantitiesOf,
6
6
  quantityRange,
7
7
  } from "../money.ts";
8
+ import type { NonEmpty } from "../types.ts";
8
9
  import type { JsonLdNode } from "./node.ts";
9
10
 
10
11
  /**
@@ -46,7 +47,7 @@ export function quantitativeValue(quantity: Quantity): JsonLdNode {
46
47
  * Reference: <https://schema.org/QuantitativeValue>
47
48
  */
48
49
  export function quantitiesFor(
49
- tiers: readonly [PriceTier, ...PriceTier[]]
50
+ tiers: NonEmpty<PriceTier>
50
51
  ): JsonLdNode | readonly JsonLdNode[] {
51
52
  return isContiguous(tiers)
52
53
  ? quantitativeValue(quantityRange(tiers))
package/src/meta/index.ts CHANGED
@@ -58,7 +58,8 @@ export type {
58
58
  } from "./content.ts";
59
59
  export type { RobotsDirective, RobotsPolicy } from "./robots.ts";
60
60
  export {
61
- META_LENGTH_LIMITS,
61
+ fitDescription,
62
+ fitTitle,
62
63
  resolveShareImage,
63
64
  type ShareImage,
64
65
  } from "./share-image.ts";
@@ -1,4 +1,5 @@
1
1
  import type { ImageAsset } from "../image.ts";
2
+ import type { NonEmpty } from "../types.ts";
2
3
  import { absoluteUrl, type HttpsUrl } from "../url.ts";
3
4
  import { warn } from "../warn.ts";
4
5
  import { link, type MetaTag } from "./tag.ts";
@@ -109,11 +110,8 @@ const NO_PREVIEW_BELOW = 200;
109
110
  * the tightest is the one that counts. Facebook's mobile preview is that: a line
110
111
  * under it survives everywhere, while a line written to Google's 160 is already
111
112
  * clipped in a share. A title is cut far sooner, by the surface people search on.
112
- *
113
- * Exported so a site that picks between copy variants fits them to these
114
- * numbers rather than a copy of them.
115
113
  */
116
- export const META_LENGTH_LIMITS = {
114
+ const LENGTH_LIMITS = {
117
115
  title: {
118
116
  limit: 60,
119
117
  why: "a search result cuts a title around there, and the end of it is usually the part that identifies the page",
@@ -131,16 +129,43 @@ export const META_LENGTH_LIMITS = {
131
129
  * nobody notices without being told.
132
130
  */
133
131
  export function warnIfClipped(
134
- field: keyof typeof META_LENGTH_LIMITS,
132
+ field: keyof typeof LENGTH_LIMITS,
135
133
  value: string,
136
134
  at: string
137
135
  ): void {
138
- const { limit, why } = META_LENGTH_LIMITS[field];
136
+ const { limit, why } = LENGTH_LIMITS[field];
139
137
  if (value.length <= limit) return;
140
138
 
141
139
  warn(at, `${field} is ${value.length} characters, past ~${limit}: ${why}.`);
142
140
  }
143
141
 
142
+ /**
143
+ * The first of several wordings, longest first, within the limit. When none
144
+ * fits, the last one — and `warnIfClipped` reports it once it reaches
145
+ * `buildMeta`.
146
+ */
147
+ function firstFitting(
148
+ field: keyof typeof LENGTH_LIMITS,
149
+ variants: NonEmpty<string>
150
+ ): string {
151
+ const { limit } = LENGTH_LIMITS[field];
152
+ // `reduce` to the last, which a non-empty tuple always has; indexing it
153
+ // would type as possibly undefined.
154
+ return (
155
+ variants.find((text) => text.length <= limit) ??
156
+ variants.reduce((_, text) => text)
157
+ );
158
+ }
159
+
160
+ /** A title as the page will print it, suffix included. */
161
+ export function fitTitle(variants: NonEmpty<string>): string {
162
+ return firstFitting("title", variants);
163
+ }
164
+
165
+ export function fitDescription(variants: NonEmpty<string>): string {
166
+ return firstFitting("description", variants);
167
+ }
168
+
144
169
  /**
145
170
  * Warns about a share image rather than rejecting it.
146
171
  *
package/src/money.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { NonEmpty } from "./types.ts";
2
+
1
3
  /**
2
4
  * An ISO 4217 currency code: `"EUR"`, `"RON"`, `"GBP"`.
3
5
  *
@@ -230,7 +232,7 @@ export interface TieredPrice {
230
232
  * The bands, cheapest quantity first. Never empty — a product with no price
231
233
  * at all is an offer of nothing.
232
234
  */
233
- readonly tiers: readonly [PriceTier, ...PriceTier[]];
235
+ readonly tiers: NonEmpty<PriceTier>;
234
236
  /**
235
237
  * What the quantity counts: `"players"`, `"seats"`, `"nights"`.
236
238
  *
@@ -259,9 +261,7 @@ export function tierRange(tier: PriceTier): QuantityRange {
259
261
  * a caller wants is `formatQuantities` or `quantitiesFor`, which pick between
260
262
  * this and `quantitiesOf` by reading the tiers.
261
263
  */
262
- export function quantityRange(
263
- tiers: readonly [PriceTier, ...PriceTier[]]
264
- ): QuantityRange {
264
+ export function quantityRange(tiers: NonEmpty<PriceTier>): QuantityRange {
265
265
  const first = tierRange(tiers[0]);
266
266
  const last = tiers.at(-1);
267
267
  return {
@@ -285,9 +285,7 @@ export function quantityRange(
285
285
  * different things: a contiguous table is one `QuantitativeValue` with a min and
286
286
  * a max, and a table with holes has to enumerate.
287
287
  */
288
- export function quantitiesOf(
289
- tiers: readonly [PriceTier, ...PriceTier[]]
290
- ): readonly number[] {
288
+ export function quantitiesOf(tiers: NonEmpty<PriceTier>): readonly number[] {
291
289
  const sizes = new Set<number>();
292
290
  for (const tier of tiers) {
293
291
  const band = tierRange(tier);
@@ -305,9 +303,7 @@ export function quantitiesOf(
305
303
  * broken set has to be listed. Derived rather than declared, so it cannot
306
304
  * disagree with the tiers it describes.
307
305
  */
308
- export function isContiguous(
309
- tiers: readonly [PriceTier, ...PriceTier[]]
310
- ): boolean {
306
+ export function isContiguous(tiers: NonEmpty<PriceTier>): boolean {
311
307
  const sizes = quantitiesOf(tiers);
312
308
  const first = sizes[0];
313
309
  const last = sizes.at(-1);
@@ -335,7 +331,7 @@ export function isContiguous(
335
331
  * language and no opinion about dashes.
336
332
  */
337
333
  export function formatQuantities(
338
- tiers: readonly [PriceTier, ...PriceTier[]],
334
+ tiers: NonEmpty<PriceTier>,
339
335
  format: {
340
336
  /** Between the ends of a run: `2–6`. */
341
337
  readonly between?: string;
package/src/types.ts CHANGED
@@ -13,6 +13,9 @@
13
13
  */
14
14
  export type StringKeys<T> = keyof T & string;
15
15
 
16
+ /** A list with at least one item, so `list[0]` needs no `undefined` check. */
17
+ export type NonEmpty<T> = readonly [T, ...T[]];
18
+
16
19
  /**
17
20
  * `true` when `T` is exactly `never`.
18
21
  *