@escape-game-over/atlas 0.1.83 → 0.1.85

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/README.md CHANGED
@@ -109,6 +109,10 @@ export const { project, redirects } = atlas.project(baseMessages, baseRoutes, {.
109
109
  export const site = atlas.site(baseMessages, baseRoutes, project)
110
110
  ```
111
111
 
112
+ A site that publishes one language under several regions writes the words once:
113
+ `atlas.perLanguage({ en: "Home", fr: "Accueil" })` fills in `en-GB`, `en-IE` and
114
+ `fr-FR` for one entry of `atlas.messages`, with placeholders still checked.
115
+
112
116
  Step 4 returns `t()`, `rich()`, `plain()`, `pathFor()`, `urlFor()`, `fileUrl()`,
113
117
  `localeLinksFor()`, `metaFor()`, `breadcrumbFor()`,
114
118
  `getStaticPaths()`, `routes`, `sitemap()`, `robots()` and `llms()` already
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.83",
3
+ "version": "0.1.85",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -58,11 +58,11 @@
58
58
  "vite": ">= 8"
59
59
  },
60
60
  "devDependencies": {
61
- "@biomejs/biome": "2.6.4",
61
+ "@biomejs/biome": "2.6.5",
62
62
  "@types/node": "26.6.6",
63
- "@vitest/coverage-istanbul": "5.0.5",
64
- "astro": "7.4.4",
63
+ "@vitest/coverage-istanbul": "5.1.0",
64
+ "astro": "7.4.5",
65
65
  "typescript": "6.0.3",
66
- "vitest": "5.0.5"
66
+ "vitest": "5.1.0"
67
67
  }
68
68
  }
@@ -0,0 +1,45 @@
1
+ /** `"en"` for `"en-GB"`: everything before the first dash. */
2
+ export type LanguageOf<L extends string> =
3
+ L extends `${infer Language}-${string}` ? Language : never;
4
+
5
+ /**
6
+ * Every locale, holding its language's text.
7
+ *
8
+ * Keeps each string's literal type, so a message built this way has its
9
+ * placeholders checked exactly like one written out by hand.
10
+ */
11
+ export type PerLanguage<L extends string, T> = {
12
+ readonly [K in L]: T[LanguageOf<K> & keyof T];
13
+ };
14
+
15
+ /**
16
+ * One message, written once per language and filled in for each of its tags.
17
+ *
18
+ * For a site that publishes one language under several regions, where `en-GB`
19
+ * and `en-IE` are different tags for `hreflang` but the same words. Checked here
20
+ * as well as in the types, because the cast below stops the compiler seeing
21
+ * this body, and a caller's `as any` would otherwise publish `undefined`.
22
+ */
23
+ export function expandLanguages<L extends string, T>(
24
+ locales: readonly L[],
25
+ texts: T
26
+ ): PerLanguage<L, T> {
27
+ const given = texts as Readonly<Record<string, string>>;
28
+ const languages = new Set(locales.map((tag) => tag.split("-")[0]));
29
+ for (const language of Object.keys(given)) {
30
+ if (!languages.has(language)) {
31
+ throw new Error(
32
+ `perLanguage: "${language}" is not a language this site declares.`
33
+ );
34
+ }
35
+ }
36
+ const entries = locales.map((tag) => {
37
+ const language = tag.split("-")[0] as string;
38
+ const text = given[language];
39
+ if (text === undefined) {
40
+ throw new Error(`perLanguage: no text for "${language}" (${tag}).`);
41
+ }
42
+ return [tag, text];
43
+ });
44
+ return Object.fromEntries(entries) as PerLanguage<L, T>;
45
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type PostalAddress,
6
6
  } from "../contact.ts";
7
7
  import type { ImageAsset } from "../image.ts";
8
+ import type { IsoDate } from "../types.ts";
8
9
  import { absoluteUrl, type HttpsUrl, joinUrl, type UrlPath } from "../url.ts";
9
10
  import { organizationId } from "./ids.ts";
10
11
  import { alternateName, type JsonLdNode } from "./node.ts";
@@ -15,6 +16,16 @@ export interface OrganizationInput {
15
16
  readonly name: string;
16
17
  /** The brand's own home. Also its identity, see `@id` below. */
17
18
  readonly url: HttpsUrl;
19
+ /**
20
+ * What the brand is, in a sentence. Google lists it as a recommended
21
+ * property of an organization.
22
+ */
23
+ readonly description?: string;
24
+ /**
25
+ * When the brand was founded: a full date, or just the year where that is
26
+ * all anyone can honestly state. Both are valid ISO 8601.
27
+ */
28
+ readonly foundingDate?: IsoDate | `${number}`;
18
29
  /**
19
30
  * Another name the brand actually goes by — an acronym, a short form.
20
31
  *
@@ -162,6 +173,12 @@ export function organization(brand: OrganizationInput): JsonLdNode {
162
173
  name: brand.name,
163
174
  url: brand.url,
164
175
  ...alternateName(brand.name, brand.alternateName),
176
+ ...(brand.description === undefined
177
+ ? {}
178
+ : { description: brand.description }),
179
+ ...(brand.foundingDate === undefined
180
+ ? {}
181
+ : { foundingDate: brand.foundingDate }),
165
182
  ...(brand.logo === undefined
166
183
  ? {}
167
184
  : { logo: joinUrl(brand.url, brand.logo) }),
package/src/llms.ts CHANGED
@@ -68,6 +68,11 @@ export interface LlmsInput {
68
68
  readonly tag: string;
69
69
  readonly url: HttpsUrl;
70
70
  }[];
71
+ /**
72
+ * Whether any page's slug differs between languages. Decides what the file
73
+ * tells a reader about reaching the same page in another one.
74
+ */
75
+ readonly translatedSlugs?: boolean;
71
76
  readonly sections: readonly LlmsSection[];
72
77
  }
73
78
 
@@ -104,14 +109,18 @@ export function buildLlms(input: LlmsInput): GeneratedFile {
104
109
  // Which language the links are in, and how to get to the others — stated
105
110
  // only when there *are* others.
106
111
  //
107
- // Not "swap the prefix": a slug is translated too, so the Greek `/about-us`
108
- // is `/el-GR/sxetika-me-emas` and a reader told to substitute the prefix
109
- // would construct a URL that 404s. The sitemap is where that mapping
110
- // actually lives, one `xhtml:link` per page per language, so this points at
111
- // it rather than describing a rule that does not hold.
112
+ // Whether "swap the prefix" works depends on the site, so it is stated only
113
+ // where it holds. Where a slug is translated, the Greek `/about-us` is
114
+ // `/el-GR/sxetika-me-emas` and a reader told to substitute the prefix would
115
+ // construct a URL that 404s; the sitemap is where that mapping lives, one
116
+ // `xhtml:link` per page per language.
112
117
  if (input.otherLanguages !== undefined && input.otherLanguages.length > 0) {
113
118
  lines.push(
114
- `Every URL below is the ${input.language.tag} version. Other languages translate their slugs, so the same page is not this URL with a different prefix — the sitemap lists each page in every language.`,
119
+ `Every URL below is the ${input.language.tag} version. ${
120
+ input.translatedSlugs === true
121
+ ? "Other languages might translate their slugs, so the same page is not always this URL with a different prefix —"
122
+ : "Other languages use the same path under their own address above —"
123
+ } the sitemap lists each page in every language.`,
115
124
  ""
116
125
  );
117
126
  }
@@ -761,6 +761,18 @@ export function createSite<
761
761
  tag: localeMeta[other].htmlLang,
762
762
  url: joinUrl(siteUrl, buildPath("", other, pathContext)),
763
763
  })),
764
+ // Read off the routes, so the file cannot claim something the URLs
765
+ // do not do.
766
+ translatedSlugs: enabledRouteIds.some((id) => {
767
+ const route = resolved[id];
768
+ return (
769
+ route !== undefined &&
770
+ others.some(
771
+ (other) =>
772
+ slugFor(route, other) !== slugFor(route, locale)
773
+ )
774
+ );
775
+ }),
764
776
  sections,
765
777
  });
766
778
  }
@@ -10,6 +10,11 @@ import {
10
10
  type NoExtraLocales,
11
11
  type ValidateBase,
12
12
  } from "../i18n/define.ts";
13
+ import {
14
+ expandLanguages,
15
+ type LanguageOf,
16
+ type PerLanguage,
17
+ } from "../i18n/per-language.ts";
13
18
  import {
14
19
  bindRedirects,
15
20
  type DefinedProject,
@@ -86,6 +91,13 @@ export function defineSite<
86
91
  return defineMessages(config, catalog as never) as T;
87
92
  },
88
93
 
94
+ /** One message per language, filled in for each of that language's tags. */
95
+ perLanguage<const T extends Readonly<Record<LanguageOf<L>, string>>>(
96
+ texts: T & Readonly<Record<Exclude<keyof T, LanguageOf<L>>, never>>
97
+ ): PerLanguage<L, T> {
98
+ return expandLanguages(Object.keys(config.locales) as L[], texts);
99
+ },
100
+
89
101
  /** One deployment: what differs from the defaults. */
90
102
  project<
91
103
  const Catalog extends BaseCatalog<L>,