@escape-game-over/atlas 0.1.5 → 0.1.7

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.
@@ -1,3 +1,4 @@
1
+ import { assertNoMarks } from "../content/marks.ts";
1
2
  import type { StringKeys } from "../types.ts";
2
3
  import type { EntryPlaceholders } from "./placeholders.ts";
3
4
 
@@ -32,7 +33,7 @@ const NAME = /^[a-zA-Z0-9_]+$/;
32
33
  * unmatched closer to be ambiguous about, and rejecting it would fail copy that
33
34
  * is merely writing a bracket.
34
35
  */
35
- function render(
36
+ export function render(
36
37
  template: string,
37
38
  at: string,
38
39
  value: (name: string, whole: string) => string
@@ -88,12 +89,42 @@ export interface RawTranslateOptions<L extends string> {
88
89
  readonly locale: L;
89
90
  }
90
91
 
92
+ /**
93
+ * One message's raw copy, before anything is substituted or parsed.
94
+ *
95
+ * Shared with `content/rich.ts`, which needs the template *unrendered* — marks
96
+ * are parsed out of it first and placeholders filled per run afterwards, so it
97
+ * cannot go through `t()`. Both failures are stated once here rather than in
98
+ * each caller, which is what keeps the two error messages identical.
99
+ */
100
+ export function lookupTemplate<L extends string>(
101
+ catalog: MergedCatalog<L>,
102
+ locale: L,
103
+ key: string
104
+ ): string {
105
+ const entry = catalog[key];
106
+ if (entry === undefined) {
107
+ throw new Error(`Unknown message key "${key}".`);
108
+ }
109
+ const template = entry[locale];
110
+ if (typeof template !== "string") {
111
+ throw new Error(`Message "${key}" has no copy for locale "${locale}".`);
112
+ }
113
+ return template;
114
+ }
115
+
91
116
  /**
92
117
  * Builds the runtime half of `t()`.
93
118
  *
94
119
  * Every failure here is a build-time failure by design: a static site should not
95
120
  * ship a page with a literal `{name}` in the markup, so a missing message or an
96
121
  * unfilled placeholder throws rather than degrading.
122
+ *
123
+ * That rule is why a message carrying `[marks]` is refused here too. `t()`
124
+ * returns a string, so the only thing it could do with them is print them, and
125
+ * `[v:accent]Game Over[/v]` rendered into a `<title>` is the literal-brace
126
+ * failure wearing different brackets. `rich()` reads those, and `plain()` gives
127
+ * back the sentence without them.
97
128
  */
98
129
  export function createRawTranslate<L extends string>(
99
130
  options: RawTranslateOptions<L>
@@ -101,16 +132,8 @@ export function createRawTranslate<L extends string>(
101
132
  const { catalog, locale } = options;
102
133
 
103
134
  return (key, params) => {
104
- const entry = catalog[key];
105
- if (entry === undefined) {
106
- throw new Error(`Unknown message key "${key}".`);
107
- }
108
- const template = entry[locale];
109
- if (typeof template !== "string") {
110
- throw new Error(
111
- `Message "${key}" has no copy for locale "${locale}".`
112
- );
113
- }
135
+ const template = lookupTemplate(catalog, locale, key);
136
+ assertNoMarks(template, `Message "${key}" (${locale})`);
114
137
  return render(
115
138
  template,
116
139
  `Message "${key}" (${locale})`,
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  CONSENT_UPDATE_GLOBAL,
27
27
  type ConsentDefaults,
28
28
  type ConsentState,
29
+ consentRequired,
29
30
  type GoogleSettings,
30
31
  type UmamiReplay,
31
32
  type UmamiSettings,
@@ -45,12 +46,17 @@ export {
45
46
  type CallingCode,
46
47
  type Coordinates,
47
48
  type CountryCode,
49
+ type E164,
48
50
  type EmailAddress,
49
51
  e164,
52
+ e164Of,
50
53
  formatPhone,
54
+ isEmailAddress,
55
+ type MailtoUrl,
51
56
  mailtoHref,
52
57
  type PhoneNumber,
53
58
  type PostalAddress,
59
+ type TelUrl,
54
60
  telHref,
55
61
  } from "./contact.ts";
56
62
  export {
@@ -60,6 +66,19 @@ export {
60
66
  type MailEndpoint,
61
67
  sendContactMessage,
62
68
  } from "./contact-form.ts";
69
+ // `parseMarks`, `assertNoMarks` and `createRichText` are deliberately not here.
70
+ // They are how lib gets from a message to a `Span[]`, and a consumer holding
71
+ // them has a second `rich()` that is not bound to the site's route table — so
72
+ // a slot filled with a route id resolves against nothing there. `site.rich(locale)` is
73
+ // the only way in, and `Span` is what a renderer switches on.
74
+ export {
75
+ type LinkTarget,
76
+ type PlainTextFor,
77
+ plain,
78
+ type RichText,
79
+ type RichTextFor,
80
+ type Span,
81
+ } from "./content/index.ts";
63
82
  export {
64
83
  COUNTRY_CODES,
65
84
  countriesFor,
@@ -227,7 +246,9 @@ export type { Sitemap, SitemapConfig } from "./sitemap.ts";
227
246
  export type { IsoDate, Letter, StringKeys } from "./types.ts";
228
247
  export {
229
248
  absoluteUrl,
249
+ type Hash,
230
250
  type HttpsUrl,
251
+ isHash,
231
252
  isHttpsUrl,
232
253
  isUrlPath,
233
254
  joinUrl,
package/src/meta/tag.ts CHANGED
@@ -92,6 +92,15 @@ export function asMetaTag(script: AnalyticsTag): MetaTag {
92
92
  if (script.kind === "noscriptFrame") {
93
93
  return { kind: "noscriptFrame", src: script.src };
94
94
  }
95
+ if (script.kind === "preconnect") {
96
+ // A `link` rather than a kind of its own: the head vocabulary already
97
+ // has the shape, and a preconnect is a link in every sense the renderer
98
+ // cares about. See `AnalyticsTag` for why no `crossorigin`.
99
+ return {
100
+ kind: "link",
101
+ attrs: { rel: "preconnect", href: script.origin },
102
+ };
103
+ }
95
104
  const unhandled: never = script;
96
105
  throw new Error(`Unhandled analytics tag: ${JSON.stringify(unhandled)}`);
97
106
  }
package/src/site/api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedLocaleMeta } from "../config.ts";
2
+ import type { PlainTextFor, RichTextFor } from "../content/index.ts";
2
3
  import type { GeneratedFile } from "../file.ts";
3
4
  import type { PublicFilePath } from "../files.ts";
4
5
  import type { TranslateFor } from "../i18n/translate.ts";
@@ -161,6 +162,36 @@ export interface Site<
161
162
  /** A locale-bound `t()`. */
162
163
  translate(locale: L): TranslateFor<Catalog>;
163
164
 
165
+ /**
166
+ * A locale-bound `rich()`: the same catalog, read as styled runs.
167
+ *
168
+ * For copy that is one sentence with a bold clause, an accent colour or a
169
+ * link in the middle of it. The marks live in the message so every language
170
+ * can move them — see `content/marks.ts`, which is mostly about why they
171
+ * cannot live in config instead.
172
+ *
173
+ * Bound to a locale here rather than taking one per call, because a link
174
+ * inside the copy resolves to *this* language's path, and a `rich()` that
175
+ * took the locale late would be a second place to get that wrong.
176
+ */
177
+ rich(locale: L): RichTextFor<Catalog, RouteId>;
178
+
179
+ /**
180
+ * A locale-bound `plain()`: the same catalog, read as words alone.
181
+ *
182
+ * For everywhere a string is what fits and markup is not — `llms()`'s
183
+ * `describe`, a page description, a structured-data `description`. Marks are
184
+ * dropped, a `[br]` becomes a space, and the result is the sentence a reader
185
+ * would hear.
186
+ *
187
+ * The reason it exists next to `translate` rather than being folded into it:
188
+ * `t()` refuses a message carrying marks, because the only thing it could do
189
+ * with them is print them. This is the reading that copes, so a description
190
+ * does not break the day someone emphasises a clause in the sentence it
191
+ * quotes.
192
+ */
193
+ plain(locale: L): PlainTextFor<Catalog>;
194
+
164
195
  /** Absolute path of a route in one locale, e.g. `/el/sxetika-me-emas`. */
165
196
  pathFor(id: RouteId, locale: L, options?: LinkOptions): UrlPath;
166
197
  /** The same, prefixed with the site origin — for canonicals and sitemaps. */
@@ -4,6 +4,11 @@ import {
4
4
  mergeLocaleMeta,
5
5
  type SiteConfigShape,
6
6
  } from "../config.ts";
7
+ import {
8
+ createPlainText,
9
+ createRichText,
10
+ type LinkResolver,
11
+ } from "../content/index.ts";
7
12
  import type { GeneratedFile } from "../file.ts";
8
13
  import type { PublicFilePath } from "../files.ts";
9
14
  import { type BaseCatalog, mergeCatalog } from "../i18n/define.ts";
@@ -729,6 +734,36 @@ export function createSite<
729
734
  });
730
735
  }
731
736
 
737
+ /**
738
+ * How a slot filled with a route id becomes this locale's path.
739
+ *
740
+ * `pathFor` already refuses an unknown or disabled route, and its message is
741
+ * right for a call site that named the id — but a mark comes from a
742
+ * *translation*, so the useful part is which message to open. That is what
743
+ * this adds; the original is kept as the cause rather than restated, since
744
+ * "disabled for this project" and "unknown" are different fixes.
745
+ */
746
+ function richLink(locale: L): LinkResolver {
747
+ return (routeId, at, hash) => {
748
+ try {
749
+ // Through `pathFor` rather than concatenated on afterwards, so
750
+ // a fragment lands after the page segment and the query — the
751
+ // one order that is right — and so `#` is added in exactly one
752
+ // place in this package.
753
+ return pathFor(
754
+ routeId as RouteId,
755
+ locale,
756
+ hash === undefined ? undefined : { hash }
757
+ );
758
+ } catch (cause) {
759
+ throw new Error(
760
+ `${at} links to "${routeId}", which is not a page this project builds. A link target is a route id — optionally with a fragment, as "about#team" — a "/path", an https:// URL, or a "#fragment" on the page it is rendered on.`,
761
+ { cause }
762
+ );
763
+ }
764
+ };
765
+ }
766
+
732
767
  return {
733
768
  locales,
734
769
  defaultLocale,
@@ -740,6 +775,20 @@ export function createSite<
740
775
  routes: enabledRouteIds,
741
776
 
742
777
  translate: (locale) => translateFactory(catalog, locale),
778
+ rich: (locale) =>
779
+ createRichText<Catalog, L, RouteId>({
780
+ catalog,
781
+ locale,
782
+ link: richLink(locale),
783
+ origin: siteUrl,
784
+ }),
785
+ plain: (locale) =>
786
+ createPlainText<Catalog, L>({
787
+ catalog,
788
+ locale,
789
+ link: richLink(locale),
790
+ origin: siteUrl,
791
+ }),
743
792
 
744
793
  pathFor,
745
794
  urlFor,
package/src/url.ts CHANGED
@@ -46,6 +46,26 @@ export function isUrlPath(path: string): path is UrlPath {
46
46
  return path.startsWith("/");
47
47
  }
48
48
 
49
+ /**
50
+ * A fragment on whatever page is being rendered: `#booking`, `#Scenarios`.
51
+ *
52
+ * The one link target lib cannot resolve and does not need to. A route id
53
+ * becomes a different path in every language and an `https://` URL points
54
+ * somewhere else entirely; a fragment names an element of the document it is
55
+ * written in, so it is already the whole answer and passes through untouched.
56
+ *
57
+ * Typed as its own shape rather than folded into `UrlPath` because they are not
58
+ * interchangeable: `/booking` navigates and `#booking` scrolls, and a leading
59
+ * character is the only thing that distinguishes them. See `LinkTarget`, which
60
+ * tells the four apart by exactly that.
61
+ */
62
+ export type Hash = `#${string}`;
63
+
64
+ /** The runtime half of `Hash`. See `isHttpsUrl`. */
65
+ export function isHash(value: string): value is Hash {
66
+ return value.startsWith("#");
67
+ }
68
+
49
69
  /**
50
70
  * An origin without its trailing slash.
51
71
  *