@secorto/i18n 0.0.4 → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ # MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Sergio Orozco Torres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/dist/index.d.mts CHANGED
@@ -77,6 +77,7 @@ declare function isMissing<L extends string>(link: TranslationLink<L>): link is
77
77
  * @throws {Error} If `links` is empty or if no accessible link exists.
78
78
  */
79
79
  declare function resolveDefaultAccessibleLink<L extends string>(links: TranslationLink<L>[], defaultLang: L): AccessibleTranslationLink<L>;
80
+ declare function createAvailableLinks<L extends string>(locales: Locales<L>, getHref: (locale: L) => string): AvailableLink<L>[];
80
81
  //#endregion
81
82
  //#region src/core/standalone.d.ts
82
83
  /**
@@ -145,7 +146,7 @@ interface SectionRoutes<Section extends string, Language extends string> {
145
146
  /**
146
147
  * Returns the configured section identifiers.
147
148
  */
148
- getSections(): Section[];
149
+ getSections(): readonly Section[];
149
150
  /**
150
151
  * Returns the localized slug for a section.
151
152
  *
@@ -171,6 +172,15 @@ interface SectionRoutes<Section extends string, Language extends string> {
171
172
  * @returns Full URL for the entry.
172
173
  */
173
174
  getEntryURL(section: Section, locale: Language, slug: string): string;
175
+ /**
176
+ * Returns the localized URL for a tag page inside a section.
177
+ *
178
+ * @param section Section identifier.
179
+ * @param locale Locale identifier.
180
+ * @param tag Tag name.
181
+ * @returns Full URL for the tag page.
182
+ */
183
+ getEntryTagURL(section: Section, locale: Language, tag: string): string;
174
184
  }
175
185
  /**
176
186
  * Constructs a nominal SectionRoutes value from a raw SectionDictionary.
@@ -209,11 +219,16 @@ interface LocalizedEntry<TEntry, L extends string> {
209
219
  * @template E - The type of the content (e.g., { title: string, body: string }).
210
220
  * @template C - The section of the application (e.g., 'blog', 'docs').
211
221
  */
212
- type TranslationIndex<L extends string, TEntry> = Record<string, Partial<Record<L, LocalizedEntry<TEntry, L>>>>;
222
+ type TranslationGroup<L extends string, TEntry> = Partial<Record<L, LocalizedEntry<TEntry, L>>>;
223
+ type TranslationIndex<L extends string, TEntry> = Partial<Record<string, TranslationGroup<L, TEntry>>>;
213
224
  /**
214
225
  * Builds a translation index from an array of localized entries.
215
226
  * The index groups entries by their translation key and locale.
216
227
  * If duplicate entries for the same translation key and locale are found, an error is thrown.
228
+ *
229
+ * Invariant: a key is only inserted into the map after at least one locale
230
+ * has been assigned to it. An empty group is therefore an invalid state.
231
+ *
217
232
  * @template E - The type of the content (e.g., { title: string, body: string }).
218
233
  * @template C - The section of the application (e.g., 'blog', 'docs').
219
234
  * @template L - The language code (e.g., 'es', 'en').
@@ -252,7 +267,7 @@ type DetailPath<C extends string, L extends string, TEntry extends GenericCollec
252
267
  props: {
253
268
  entry: LocalizedEntry<TEntry, L>;
254
269
  section: C;
255
- siblings: TranslationIndex<L, TEntry>[string];
270
+ siblings: NonNullable<TranslationIndex<L, TEntry>[string]>;
256
271
  };
257
272
  };
258
273
  /**
@@ -296,4 +311,16 @@ declare function getStaticPathsEntries<C extends string, E extends object, L ext
296
311
  */
297
312
  declare function createDetailTranslationLinks<C extends string, L extends string, TEntry extends GenericCollectionEntry<C, object>>(siblings: Partial<Record<L, LocalizedEntry<TEntry, L>>>, sectionRoutes: SectionRoutes<C, L>, locales: Locales<L>): TranslationLink<L>[];
298
313
  //#endregion
299
- export { AccessibleTranslationLink, AvailableLink, DetailPath, DraftLink, GenericCollectionEntry, Locales, LocalizedEntry, MissingLink, SectionDictionary, SectionRoutes, StandalonePageEntry, StandalonePageIndex, TranslationIndex, TranslationLink, adaptToLocalizedEntry, availableLink, createDetailTranslationLinks, createLocales, createSectionRoutes, createStandalonePageLinks, createTranslationIndex, draftLink, extractCleanId, getStaticPathsEntries, isAccessible, isAvailable, isDraft, isMissing, missingLink, resolveDefaultAccessibleLink, resolveTranslationKey };
314
+ //#region src/section/list-paths.d.ts
315
+ interface SectionPath<TLocale extends string, TSection extends string> {
316
+ params: {
317
+ locale: TLocale;
318
+ section: string;
319
+ };
320
+ props: {
321
+ section: TSection;
322
+ };
323
+ }
324
+ declare function getStaticPathsSections<C extends string, L extends string>(routes: SectionRoutes<C, L>, allowedLocales: Locales<L>): Promise<SectionPath<L, C>[]>;
325
+ //#endregion
326
+ export { AccessibleTranslationLink, AvailableLink, DetailPath, DraftLink, GenericCollectionEntry, Locales, LocalizedEntry, MissingLink, SectionDictionary, SectionPath, SectionRoutes, StandalonePageEntry, StandalonePageIndex, TranslationGroup, TranslationIndex, TranslationLink, adaptToLocalizedEntry, availableLink, createAvailableLinks, createDetailTranslationLinks, createLocales, createSectionRoutes, createStandalonePageLinks, createTranslationIndex, draftLink, extractCleanId, getStaticPathsEntries, getStaticPathsSections, isAccessible, isAvailable, isDraft, isMissing, missingLink, resolveDefaultAccessibleLink, resolveTranslationKey };
package/dist/index.mjs CHANGED
@@ -90,6 +90,13 @@ function resolveDefaultAccessibleLink(links, defaultLang) {
90
90
  if (firstDraft) return firstDraft;
91
91
  throw new Error("resolveDefaultAccessibleLink: expected at least one accessible link");
92
92
  }
93
+ function createAvailableLinks(locales, getHref) {
94
+ return locales.all.map((locale) => ({
95
+ type: "available",
96
+ href: getHref(locale),
97
+ locale
98
+ }));
99
+ }
93
100
  //#endregion
94
101
  //#region src/core/extract-id.ts
95
102
  /**
@@ -155,7 +162,8 @@ function createStandalonePageLinks(path, translationKey, index, locales) {
155
162
  */
156
163
  function createSectionRoutes(routes) {
157
164
  const seen = /* @__PURE__ */ new Map();
158
- for (const section of Object.keys(routes)) {
165
+ const sections = Object.freeze(Object.keys(routes));
166
+ for (const section of sections) {
159
167
  const localized = routes[section];
160
168
  for (const locale of Object.keys(localized)) {
161
169
  const slug = localized[locale];
@@ -167,16 +175,20 @@ function createSectionRoutes(routes) {
167
175
  seen.set(key, section);
168
176
  }
169
177
  }
170
- const getSections = () => Object.keys(routes);
178
+ for (const section of sections) Object.freeze(routes[section]);
179
+ Object.freeze(routes);
180
+ const getSections = () => sections;
171
181
  const getSectionRoute = (section, locale) => routes[section][locale];
172
182
  const getSectionURL = (section, locale) => `/${locale}/${getSectionRoute(section, locale)}`;
183
+ const getEntryTagURL = (section, locale, tag) => `${getSectionURL(section, locale)}/tags/${encodeURIComponent(tag)}`;
173
184
  const getEntryURL = (section, locale, slug) => `${getSectionURL(section, locale)}/${slug}`;
174
185
  return {
175
186
  routes,
176
187
  getSections,
177
188
  getSectionRoute,
178
189
  getSectionURL,
179
- getEntryURL
190
+ getEntryURL,
191
+ getEntryTagURL
180
192
  };
181
193
  }
182
194
  //#endregion
@@ -185,6 +197,10 @@ function createSectionRoutes(routes) {
185
197
  * Builds a translation index from an array of localized entries.
186
198
  * The index groups entries by their translation key and locale.
187
199
  * If duplicate entries for the same translation key and locale are found, an error is thrown.
200
+ *
201
+ * Invariant: a key is only inserted into the map after at least one locale
202
+ * has been assigned to it. An empty group is therefore an invalid state.
203
+ *
188
204
  * @template E - The type of the content (e.g., { title: string, body: string }).
189
205
  * @template C - The section of the application (e.g., 'blog', 'docs').
190
206
  * @template L - The language code (e.g., 'es', 'en').
@@ -199,7 +215,7 @@ function createTranslationIndex(entries) {
199
215
  const locale = entry.locale;
200
216
  let group = map.get(key);
201
217
  if (!group) {
202
- group = {};
218
+ group = Object.create(null);
203
219
  map.set(key, group);
204
220
  } else if (locale in group) throw new Error(`Duplicate translation for key "${key}" and locale "${locale}"`);
205
221
  group[locale] = entry;
@@ -263,18 +279,22 @@ async function getStaticPathsEntries(routes, fetchCollection, allowedLocales) {
263
279
  for (const sectionKey of routes.getSections()) {
264
280
  const localizedEntries = (await fetchCollection(sectionKey)).map((entry) => adaptToLocalizedEntry(entry, allowedLocales));
265
281
  const index = createTranslationIndex(localizedEntries);
266
- for (const localized of localizedEntries) allPaths.push({
267
- params: {
268
- locale: localized.locale,
269
- section: routes.getSectionRoute(sectionKey, localized.locale),
270
- id: localized.cleanId
271
- },
272
- props: {
273
- entry: localized,
274
- section: sectionKey,
275
- siblings: index[localized.translationKey]
276
- }
277
- });
282
+ for (const localized of localizedEntries) {
283
+ const siblings = index[localized.translationKey];
284
+ if (!siblings || Object.keys(siblings).length === 0) throw new Error(`Missing translation group for key "${localized.translationKey}"`);
285
+ allPaths.push({
286
+ params: {
287
+ locale: localized.locale,
288
+ section: routes.getSectionRoute(sectionKey, localized.locale),
289
+ id: localized.cleanId
290
+ },
291
+ props: {
292
+ entry: localized,
293
+ section: sectionKey,
294
+ siblings
295
+ }
296
+ });
297
+ }
278
298
  }
279
299
  return allPaths;
280
300
  }
@@ -301,4 +321,17 @@ function createDetailTranslationLinks(siblings, sectionRoutes, locales) {
301
321
  });
302
322
  }
303
323
  //#endregion
304
- export { adaptToLocalizedEntry, availableLink, createDetailTranslationLinks, createLocales, createSectionRoutes, createStandalonePageLinks, createTranslationIndex, draftLink, extractCleanId, getStaticPathsEntries, isAccessible, isAvailable, isDraft, isMissing, missingLink, resolveDefaultAccessibleLink, resolveTranslationKey };
324
+ //#region src/section/list-paths.ts
325
+ async function getStaticPathsSections(routes, allowedLocales) {
326
+ const allPaths = [];
327
+ for (const sectionKey of routes.getSections()) for (const locale of allowedLocales.all) allPaths.push({
328
+ params: {
329
+ locale,
330
+ section: routes.getSectionRoute(sectionKey, locale)
331
+ },
332
+ props: { section: sectionKey }
333
+ });
334
+ return allPaths;
335
+ }
336
+ //#endregion
337
+ export { adaptToLocalizedEntry, availableLink, createAvailableLinks, createDetailTranslationLinks, createLocales, createSectionRoutes, createStandalonePageLinks, createTranslationIndex, draftLink, extractCleanId, getStaticPathsEntries, getStaticPathsSections, isAccessible, isAvailable, isDraft, isMissing, missingLink, resolveDefaultAccessibleLink, resolveTranslationKey };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secorto/i18n",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "private": false,
5
5
  "sideEffects": false,
6
6
  "description": "Framework-agnostic i18n primitive",
@@ -41,13 +41,6 @@
41
41
  "dist",
42
42
  "src"
43
43
  ],
44
- "scripts": {
45
- "build": "tsdown",
46
- "clean": "rimraf dist",
47
- "test": "vitest --run",
48
- "test:unit": "vitest",
49
- "test:unit:watch": "vitest --watch"
50
- },
51
44
  "devDependencies": {
52
45
  "vitest": "^4.1.10"
53
46
  },
@@ -55,5 +48,12 @@
55
48
  "entry": [
56
49
  "src/index.ts"
57
50
  ]
51
+ },
52
+ "scripts": {
53
+ "build": "tsdown",
54
+ "clean": "rimraf dist",
55
+ "test": "vitest --run",
56
+ "test:unit": "vitest",
57
+ "test:unit:watch": "vitest --watch"
58
58
  }
59
- }
59
+ }
@@ -1,14 +1,16 @@
1
- import { Locales } from '../core'
1
+ import type { Locales } from '../core'
2
2
  import {
3
3
  createTranslationIndex,
4
+ } from './translation-index'
5
+ import type {
4
6
  LocalizedEntry,
5
7
  TranslationIndex,
6
8
  } from './translation-index'
7
9
  import {
8
10
  adaptToLocalizedEntry,
9
- GenericCollectionEntry,
10
11
  } from './entry-adapter'
11
- import { SectionRoutes } from './routes'
12
+ import type { GenericCollectionEntry } from './entry-adapter'
13
+ import type { SectionRoutes } from './routes'
12
14
 
13
15
  export type DetailPath<
14
16
  C extends string,
@@ -23,7 +25,7 @@ export type DetailPath<
23
25
  props: {
24
26
  entry: LocalizedEntry<TEntry, L>
25
27
  section: C
26
- siblings: TranslationIndex<L, TEntry>[string]
28
+ siblings: NonNullable<TranslationIndex<L, TEntry>[string]>
27
29
  }
28
30
  }
29
31
 
@@ -76,6 +78,14 @@ export async function getStaticPathsEntries<
76
78
  const index = createTranslationIndex(localizedEntries)
77
79
 
78
80
  for (const localized of localizedEntries) {
81
+ const siblings = index[localized.translationKey]
82
+
83
+ if (!siblings || Object.keys(siblings).length === 0) {
84
+ throw new Error(
85
+ `Missing translation group for key "${localized.translationKey}"`,
86
+ )
87
+ }
88
+
79
89
  allPaths.push({
80
90
  params: {
81
91
  locale: localized.locale,
@@ -85,7 +95,7 @@ export async function getStaticPathsEntries<
85
95
  props: {
86
96
  entry: localized,
87
97
  section: sectionKey,
88
- siblings: index[localized.translationKey],
98
+ siblings,
89
99
  },
90
100
  })
91
101
  }
@@ -1,7 +1,8 @@
1
- import { availableLink, draftLink, Locales, missingLink, TranslationLink } from "../core"
2
- import { GenericCollectionEntry } from "./entry-adapter"
3
- import { SectionRoutes } from "./routes"
4
- import { LocalizedEntry } from "./translation-index"
1
+ import { availableLink, draftLink, missingLink } from '../core'
2
+ import type { Locales, TranslationLink } from '../core'
3
+ import type { GenericCollectionEntry } from './entry-adapter'
4
+ import type { SectionRoutes } from './routes'
5
+ import type { LocalizedEntry } from './translation-index'
5
6
 
6
7
  /**
7
8
  * Creates translation links for all supported locales.
@@ -1,5 +1,6 @@
1
- import { extractCleanId, Locales } from '../core'
2
- import { LocalizedEntry } from './translation-index'
1
+ import { extractCleanId } from '../core'
2
+ import type { Locales } from '../core'
3
+ import type { LocalizedEntry } from './translation-index'
3
4
 
4
5
  export interface GenericCollectionEntry<
5
6
  C extends string,
@@ -31,7 +31,7 @@ export interface SectionRoutes<
31
31
  /**
32
32
  * Returns the configured section identifiers.
33
33
  */
34
- getSections(): Section[]
34
+ getSections(): readonly Section[]
35
35
 
36
36
  /**
37
37
  * Returns the localized slug for a section.
@@ -84,8 +84,9 @@ export function createSectionRoutes<
84
84
  routes: SectionDictionary<Section, Language, string>
85
85
  ): SectionRoutes<Section, Language> {
86
86
  const seen = new Map<string, Section>()
87
+ const sections = Object.freeze(Object.keys(routes) as Section[])
87
88
 
88
- for (const section of Object.keys(routes) as Section[]) {
89
+ for (const section of sections) {
89
90
  const localized = routes[section]
90
91
 
91
92
  for (const locale of Object.keys(localized) as Language[]) {
@@ -103,7 +104,13 @@ export function createSectionRoutes<
103
104
  }
104
105
  }
105
106
 
106
- const getSections = (): Section[] => Object.keys(routes) as Section[]
107
+ // Enforce runtime immutability for the value object invariants.
108
+ for (const section of sections) {
109
+ Object.freeze(routes[section])
110
+ }
111
+ Object.freeze(routes)
112
+
113
+ const getSections = (): readonly Section[] => sections
107
114
 
108
115
  const getSectionRoute = (section: Section, locale: Language): string =>
109
116
  routes[section][locale]
@@ -21,18 +21,24 @@ export interface LocalizedEntry<
21
21
  * @template E - The type of the content (e.g., { title: string, body: string }).
22
22
  * @template C - The section of the application (e.g., 'blog', 'docs').
23
23
  */
24
+ export type TranslationGroup<
25
+ L extends string,
26
+ TEntry,
27
+ > = Partial<Record<L, LocalizedEntry<TEntry, L>>>
28
+
24
29
  export type TranslationIndex<
25
30
  L extends string,
26
31
  TEntry
27
- > = Record<
28
- string,
29
- Partial<Record<L, LocalizedEntry<TEntry, L>>>
30
- >
32
+ > = Partial<Record<string, TranslationGroup<L, TEntry>>>
31
33
 
32
34
  /**
33
35
  * Builds a translation index from an array of localized entries.
34
36
  * The index groups entries by their translation key and locale.
35
37
  * If duplicate entries for the same translation key and locale are found, an error is thrown.
38
+ *
39
+ * Invariant: a key is only inserted into the map after at least one locale
40
+ * has been assigned to it. An empty group is therefore an invalid state.
41
+ *
36
42
  * @template E - The type of the content (e.g., { title: string, body: string }).
37
43
  * @template C - The section of the application (e.g., 'blog', 'docs').
38
44
  * @template L - The language code (e.g., 'es', 'en').
@@ -46,17 +52,16 @@ export function createTranslationIndex<
46
52
  >(
47
53
  entries: readonly LocalizedEntry<TEntry, L>[]
48
54
  ): TranslationIndex<L, TEntry> {
49
- // Using map to safely mutate internally without lying to TypeScript
50
- const map = new Map<string, Partial<Record<L, LocalizedEntry<TEntry, L>>>>()
55
+ // Using map to safely mutate internally without lying to TypeScript.
56
+ const map = new Map<string, TranslationGroup<L, TEntry>>()
51
57
 
52
58
  for (const entry of entries) {
53
59
  const key = entry.translationKey
54
60
  const locale = entry.locale
55
61
 
56
- // Get or create the group for this key
57
62
  let group = map.get(key)
58
63
  if (!group) {
59
- group = {}
64
+ group = Object.create(null) as TranslationGroup<L, TEntry>
60
65
  map.set(key, group)
61
66
  } else if (locale in group) {
62
67
  throw new Error(
@@ -64,8 +69,8 @@ export function createTranslationIndex<
64
69
  )
65
70
  }
66
71
 
67
- // Assign directly to the entry
68
72
  group[locale] = entry
69
73
  }
74
+
70
75
  return Object.fromEntries(map)
71
76
  }