@escape-game-over/atlas 0.1.95 → 0.1.97

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/docs/rich-text.md CHANGED
@@ -167,6 +167,34 @@ A caller that does can build one — `joinUrl(site.urlFor(id, locale), href)`.
167
167
  address or number is written once. The number must be in international form —
168
168
  `"+30 210 0000000"` — because a local one does not dial from abroad.
169
169
 
170
+ ## Binding a slot once
171
+
172
+ A view that renders many messages through one call — a FAQ page reading every
173
+ answer in a loop — cannot pass a slot to some messages and not others: a
174
+ message that declares no slot refuses the argument, and one that does demands
175
+ it. Bind the slots when the `rich()` is made instead:
176
+
177
+ ```ts
178
+ const rich = site.rich(locale, { contact: "contact", rooms: "rooms" });
179
+
180
+ rich("faq.hours.a"); // "…on our [a:contact]Contact page[/a]." needs nothing more
181
+ rich("faq.age.a"); // no slots, still no argument
182
+ ```
183
+
184
+ Nothing is loosened:
185
+
186
+ - **A bound destination is checked where it is bound**, against the routes this
187
+ project builds, as a call's would be.
188
+ - **A slot left unbound is still required** at the call, and still a compile
189
+ error without it — for a single key and for a union of keys alike, so a loop
190
+ over answers fails to compile if one answer names a slot nobody bound.
191
+ - **A call's own destination wins** over a bound one, should it pass the same
192
+ slot; the type does not offer a bound slot to the call, so this only matters
193
+ for a key that arrived as a plain `string`.
194
+
195
+ Copy still names a slot, never an address. Binding moves where the destination
196
+ is written — to the one place that renders the copy — not into the copy.
197
+
170
198
  ## What the compiler holds in step
171
199
 
172
200
  Which marks a message uses is checked across locales by the same machinery that
@@ -253,7 +281,7 @@ per `[v:name]` and one for links — usually in a small wrapper
253
281
  ```
254
282
 
255
283
  A `[tel]` link renders with `dir="ltr"`: a number has no letters to set its
256
- direction, and on a right-to-left page it would otherwise read "2227 0244 965+".
284
+ direction, and on a right-to-left page it would otherwise read "0100 555 202 1+".
257
285
  A number on its own, outside copy, is `@escape-game-over/atlas/astro/phone`.
258
286
 
259
287
  `attrs` puts attributes on a variant's run, so a script can find it and rewrite
@@ -266,7 +294,8 @@ class nor attrs still renders its words, and warns at build.
266
294
  | --------------------------------------------------- | --------------------------------------------------------------------- |
267
295
  | A locale dropping `[a:venue]` | compile error — the marks must match across a message |
268
296
  | `[a:venue]` in one locale, `[a:booking]` in another | compile error — the argument is part of the token |
269
- | `rich()` without a slot's target | compile error, then a build error naming the slot |
297
+ | `rich()` without a slot's target, unbound | compile error, then a build error naming the slot |
298
+ | A slot bound to an unbuilt route | compile error where it is bound |
270
299
  | A slot filled with an unbuilt route | compile error at the call site |
271
300
  | `[Mon-Fri]` | build error, listing the marks and the `[[` escape |
272
301
  | `[b]` never closed, `[/b]` never opened | build error naming the message and locale |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -6,8 +6,8 @@
6
6
  * <a href={telHref(phone)}><Phone phone={phone} /></a>
7
7
  * ```
8
8
  *
9
- * "+965 2227 0244" has no letters to set its direction, so on an Arabic page
10
- * the browser would lay it out right to left and show "2227 0244 965+". Its own
9
+ * "+1 202 555 0100" has no letters to set its direction, so on an Arabic page
10
+ * the browser would lay it out right to left and show "0100 555 202 1+". Its own
11
11
  * element with `dir="ltr"` keeps it whole, the way the page expects a number
12
12
  * to read, and copies as just the number. An email needs none of this: its
13
13
  * letters already make it read left to right.
@@ -82,7 +82,7 @@ const variantAttrs = (variant: string) =>
82
82
  </a>
83
83
  );
84
84
  // A number has no letters to set its direction; on a right-to-left
85
- // page it would read "2227 0244 965+". See `Phone.astro`.
85
+ // page it would read "0100 555 202 1+". See `Phone.astro`.
86
86
  case "tel":
87
87
  return (
88
88
  <a
@@ -179,6 +179,12 @@ export interface RichTextOptions<L extends string> {
179
179
  * on a bad one. `joinUrl` already answers it once.
180
180
  */
181
181
  readonly origin: HttpsUrl;
182
+ /**
183
+ * Destinations bound once, for every message this `rich()` reads: a slot
184
+ * named here is filled without the call passing it. Already checked by
185
+ * the type that built this; strings by the time they reach the runtime.
186
+ */
187
+ readonly links?: Readonly<Record<string, string>>;
182
188
  }
183
189
 
184
190
  /** The link slots message `K` declares, e.g. `"venue"` from `[a:venue]`. */
@@ -200,8 +206,11 @@ export type RichParamsOf<
200
206
  Catalog,
201
207
  K extends StringKeys<Catalog>,
202
208
  RouteId extends string,
209
+ Bound extends string = never,
203
210
  > = Readonly<Record<MessageParamsOf<Catalog, K>, string>> &
204
- Readonly<Record<LinkNamesOf<Catalog, K>, LinkTarget<RouteId>>>;
211
+ Readonly<
212
+ Record<Exclude<LinkNamesOf<Catalog, K>, Bound>, LinkTarget<RouteId>>
213
+ >;
205
214
 
206
215
  /**
207
216
  * `[key]` for a message that needs nothing, `[key, params]` otherwise.
@@ -214,20 +223,26 @@ export type RichArgsOf<
214
223
  Catalog,
215
224
  K extends StringKeys<Catalog>,
216
225
  RouteId extends string,
217
- > = [MessageParamsOf<Catalog, K> | LinkNamesOf<Catalog, K>] extends [never]
226
+ Bound extends string = never,
227
+ > = [
228
+ MessageParamsOf<Catalog, K> | Exclude<LinkNamesOf<Catalog, K>, Bound>,
229
+ ] extends [never]
218
230
  ? [key: K]
219
- : [key: K, params: RichParamsOf<Catalog, K, RouteId>];
231
+ : [key: K, params: RichParamsOf<Catalog, K, RouteId, Bound>];
220
232
 
221
233
  /**
222
234
  * `rich()`, keyed like `t()` and asking for a little more.
223
235
  *
224
236
  * Generic over the routes as well as the catalog, which is what lets a link
225
- * slot be typed. `createSite` supplies both.
237
+ * slot be typed. `createSite` supplies both. `Bound` names the slots bound
238
+ * when this `rich()` was made, which a call no longer passes.
226
239
  */
227
- export type RichTextFor<Catalog, RouteId extends string> = <
228
- K extends StringKeys<Catalog>,
229
- >(
230
- ...args: RichArgsOf<Catalog, K, RouteId>
240
+ export type RichTextFor<
241
+ Catalog,
242
+ RouteId extends string,
243
+ Bound extends string = never,
244
+ > = <K extends StringKeys<Catalog>>(
245
+ ...args: RichArgsOf<Catalog, K, RouteId, Bound>
231
246
  ) => RichText;
232
247
 
233
248
  /** `site.plain()`, keyed and parameterised exactly as `t()` is. */
@@ -252,11 +267,16 @@ export function createRichText<
252
267
  Catalog,
253
268
  L extends string,
254
269
  RouteId extends string = string,
255
- >(options: RichTextOptions<L>): RichTextFor<Catalog, RouteId> {
256
- const { catalog, locale, link, origin } = options;
270
+ Bound extends string = never,
271
+ >(
272
+ options: RichTextOptions<L> & {
273
+ readonly links?: Readonly<Record<Bound, LinkTarget<RouteId>>>;
274
+ }
275
+ ): RichTextFor<Catalog, RouteId, Bound> {
276
+ const { catalog, locale, link, origin, links } = options;
257
277
 
258
278
  return <K extends StringKeys<Catalog>>(
259
- ...args: RichArgsOf<Catalog, K, RouteId>
279
+ ...args: RichArgsOf<Catalog, K, RouteId, Bound>
260
280
  ): RichText => {
261
281
  // The conditional tuple cannot be destructured directly; the runtime
262
282
  // shape is always `[key]` or `[key, params]`. As in `createTranslate`.
@@ -267,7 +287,7 @@ export function createRichText<
267
287
  const template = lookupTemplate(catalog, locale, String(key));
268
288
 
269
289
  return parseMarks(template, at).map((span) =>
270
- resolve(span, at, params, link, origin)
290
+ resolve(span, at, params, links, link, origin)
271
291
  );
272
292
  };
273
293
  }
@@ -276,6 +296,7 @@ function resolve(
276
296
  span: ParsedSpan,
277
297
  at: string,
278
298
  params: Readonly<Record<string, string>> | undefined,
299
+ links: Readonly<Record<string, string>> | undefined,
279
300
  link: LinkResolver,
280
301
  origin: HttpsUrl
281
302
  ): Span {
@@ -291,7 +312,7 @@ function resolve(
291
312
  case "link":
292
313
  return resolveLink(
293
314
  text,
294
- targetFor(span.name, at, params),
315
+ targetFor(span.name, at, params, links),
295
316
  at,
296
317
  link,
297
318
  origin
@@ -346,15 +367,22 @@ function fill(
346
367
  function targetFor(
347
368
  name: string,
348
369
  at: string,
349
- params: Readonly<Record<string, string>> | undefined
370
+ params: Readonly<Record<string, string>> | undefined,
371
+ links: Readonly<Record<string, string>> | undefined
350
372
  ): string {
351
- const target =
352
- params !== undefined && Object.hasOwn(params, name)
353
- ? params[name]
354
- : undefined;
373
+ // The call's own destination first, then one bound for every message.
374
+ const target = (() => {
375
+ if (params !== undefined && Object.hasOwn(params, name)) {
376
+ return params[name];
377
+ }
378
+ if (links !== undefined && Object.hasOwn(links, name)) {
379
+ return links[name];
380
+ }
381
+ return undefined;
382
+ })();
355
383
  if (target === undefined) {
356
384
  throw new Error(
357
- `${at} has no destination for the link slot "[a:${name}]". Pass one: rich(key, { ${name}: "some-route-id" }).`
385
+ `${at} has no destination for the link slot "[a:${name}]". Pass one: rich(key, { ${name}: "some-route-id" }), or bind it: site.rich(locale, { ${name}: "some-route-id" }).`
358
386
  );
359
387
  }
360
388
  return target;
@@ -15,8 +15,17 @@ import { geoCoordinates, openingHours, postalAddress } from "./place.ts";
15
15
 
16
16
  export interface LocalBusinessInput {
17
17
  readonly name: string;
18
- /** The site's own origin. Also the node's identity, see `@id` below. */
18
+ /**
19
+ * The page about it: the site, or one location's page on a site with
20
+ * several. Its origin carries the node's identity, see `@id` below.
21
+ */
19
22
  readonly url: HttpsUrl;
23
+ /**
24
+ * Which of the site's businesses this is, on a site with several: `north`
25
+ * makes the `@id` `<origin>#business-north`. Point at it with
26
+ * `businessId(url, location)`.
27
+ */
28
+ readonly location?: string;
20
29
  readonly address: PostalAddress;
21
30
  /**
22
31
  * Another name this venue actually goes by.
@@ -162,7 +171,7 @@ export interface LocalBusinessInput {
162
171
  export function localBusiness(business: LocalBusinessInput): JsonLdNode {
163
172
  return {
164
173
  "@type": schemaType(business.type ?? "LocalBusiness"),
165
- "@id": businessId(business.url),
174
+ "@id": businessId(business.url, business.location),
166
175
  name: business.name,
167
176
  ...alternateName(business.name, business.alternateName),
168
177
  url: business.url,
package/src/jsonld/ids.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type HttpsUrl, normalizeOrigin } from "../url.ts";
1
+ import { type HttpsUrl, isHttpsUrl, normalizeOrigin } from "../url.ts";
2
2
 
3
3
  /**
4
4
  * The `@id` each addressable node takes, and the types that keep them apart.
@@ -43,7 +43,9 @@ import { type HttpsUrl, normalizeOrigin } from "../url.ts";
43
43
  * -checks, while the mistake this exists to stop — passing a bare URL where a
44
44
  * reference belongs — does not.
45
45
  */
46
- export type BusinessId = `${HttpsUrl}#business`;
46
+ export type BusinessId =
47
+ | `${HttpsUrl}#business`
48
+ | `${HttpsUrl}#business-${string}`;
47
49
 
48
50
  /** The identity of a brand node. See `BusinessId`. */
49
51
  export type OrganizationId = `${HttpsUrl}#organization`;
@@ -68,15 +70,25 @@ export type WebsiteId = `${HttpsUrl}#website`;
68
70
  export type ServiceId = `${HttpsUrl}#service`;
69
71
 
70
72
  /**
71
- * The `@id` a business node takes, from the site's origin.
73
+ * The `@id` a business node takes: on the origin of `url`, whichever page of
74
+ * the site that is.
72
75
  *
73
76
  * Exported because a reference has to be built by whoever points at it, and the
74
77
  * fragment must match exactly — a node linking to `#local-business` when the
75
78
  * business calls itself `#business` is a dangling reference that no validator
76
79
  * reports and no crawler resolves. One definition, used by both ends.
80
+ *
81
+ * A site with several businesses — one per location — names each:
82
+ * `businessId(origin, "north")` is `<origin>#business-north`.
83
+ * Without a name they would all be `#business`, and a crawler merges nodes
84
+ * that share an id, so the locations would read as one place.
77
85
  */
78
- export function businessId(url: HttpsUrl): BusinessId {
79
- return `${normalizeOrigin(url)}#business`;
86
+ export function businessId(url: HttpsUrl, location?: string): BusinessId {
87
+ const origin = new URL(url).origin;
88
+ if (!isHttpsUrl(origin)) throw new Error(`"${url}" has no https origin.`);
89
+ return location === undefined
90
+ ? `${origin}#business`
91
+ : `${origin}#business-${location}`;
80
92
  }
81
93
 
82
94
  /** The `@id` a brand node takes. See `businessId`. */
package/src/site/api.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { ResolvedLocaleMeta } from "../config.ts";
2
- import type { PlainTextFor, RichTextFor } from "../content/index.ts";
2
+ import type {
3
+ LinkTarget,
4
+ PlainTextFor,
5
+ RichTextFor,
6
+ } from "../content/index.ts";
3
7
  import type { GeneratedFile } from "../file.ts";
4
8
  import type { PublicFilePath } from "../files.ts";
5
9
  import type { Fonts } from "../fonts.ts";
@@ -174,8 +178,17 @@ export interface Site<
174
178
  * Bound to a locale here rather than taking one per call, because a link
175
179
  * inside the copy resolves to *this* language's path, and a `rich()` that
176
180
  * took the locale late would be a second place to get that wrong.
181
+ *
182
+ * `links` binds slots once for every message read through it, for a view
183
+ * that renders many messages through one call: `{ contact: "contact" }`
184
+ * lets any of them write `[a:contact]` with nothing passed per call. The
185
+ * targets are checked here, as a call's would be; a slot left unbound is
186
+ * still required at the call, and still a compile error without it.
177
187
  */
178
- rich(locale: L): RichTextFor<Catalog, RouteId>;
188
+ rich<const Bound extends string = never>(
189
+ locale: L,
190
+ links?: Readonly<Record<Bound, LinkTarget<RouteId>>>
191
+ ): RichTextFor<Catalog, RouteId, Bound>;
179
192
 
180
193
  /**
181
194
  * A locale-bound `plain()`: the same catalog, read as words alone.
@@ -9,6 +9,7 @@ import {
9
9
  createPlainText,
10
10
  createRichText,
11
11
  type LinkResolver,
12
+ type LinkTarget,
12
13
  } from "../content/index.ts";
13
14
  import type { GeneratedFile } from "../file.ts";
14
15
  import type { PublicFilePath } from "../files.ts";
@@ -837,12 +838,16 @@ export function createSite<
837
838
  routes: enabledRouteIds,
838
839
 
839
840
  translate: (locale) => translateFactory(catalog, locale),
840
- rich: (locale) =>
841
- createRichText<Full, L, RouteId>({
841
+ rich: <const Bound extends string = never>(
842
+ locale: L,
843
+ links?: Readonly<Record<Bound, LinkTarget<RouteId>>>
844
+ ) =>
845
+ createRichText<Full, L, RouteId, Bound>({
842
846
  catalog,
843
847
  locale,
844
848
  link: richLink(locale),
845
849
  origin: siteUrl,
850
+ ...(links === undefined ? {} : { links }),
846
851
  }),
847
852
  plain: (locale) =>
848
853
  createPlainText<Full, L>({