@secorto/i18n 0.0.4
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 +12 -0
- package/dist/index.d.mts +299 -0
- package/dist/index.mjs +304 -0
- package/package.json +59 -0
- package/src/core/extract-id.ts +39 -0
- package/src/core/index.ts +4 -0
- package/src/core/locale.ts +30 -0
- package/src/core/standalone.ts +96 -0
- package/src/core/translationLink.ts +130 -0
- package/src/index.ts +2 -0
- package/src/section/detail-path.ts +95 -0
- package/src/section/details-links.ts +42 -0
- package/src/section/entry-adapter.ts +57 -0
- package/src/section/index.ts +5 -0
- package/src/section/routes.ts +128 -0
- package/src/section/translation-index.ts +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @secorto/i18n
|
|
2
|
+
|
|
3
|
+
Reusable building blocks for multilingual content systems.
|
|
4
|
+
|
|
5
|
+
## Goals
|
|
6
|
+
|
|
7
|
+
- Framework-agnostic core.
|
|
8
|
+
- Build-time validation.
|
|
9
|
+
- Translation-aware content navigation.
|
|
10
|
+
- Localized tags.
|
|
11
|
+
- Astro adapters.
|
|
12
|
+
- Documentation-first architecture.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
//#region src/core/locale.d.ts
|
|
2
|
+
interface Locales<L extends string> {
|
|
3
|
+
readonly all: readonly L[];
|
|
4
|
+
fromString(lang: string | undefined): L;
|
|
5
|
+
isValid(lang: string): lang is L;
|
|
6
|
+
}
|
|
7
|
+
declare function createLocales<L extends string>(locales: readonly L[]): Locales<L>;
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/core/translationLink.d.ts
|
|
10
|
+
/**
|
|
11
|
+
* Represents a translation that is available and can be accessed by users.
|
|
12
|
+
*/
|
|
13
|
+
type AvailableLink<L extends string> = {
|
|
14
|
+
type: 'available';
|
|
15
|
+
href: string;
|
|
16
|
+
locale: L;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Represents a translation that does not exist for the given locale.
|
|
20
|
+
*/
|
|
21
|
+
type MissingLink<L extends string> = {
|
|
22
|
+
type: 'missing';
|
|
23
|
+
href: null;
|
|
24
|
+
locale: L;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Represents a translation that exists as a draft but is not yet publicly available.
|
|
28
|
+
*/
|
|
29
|
+
type DraftLink<L extends string> = {
|
|
30
|
+
type: 'draft';
|
|
31
|
+
href: string;
|
|
32
|
+
locale: L;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Represents the state of a translation for a locale.
|
|
36
|
+
*/
|
|
37
|
+
type TranslationLink<L extends string> = AvailableLink<L> | MissingLink<L> | DraftLink<L>;
|
|
38
|
+
/**
|
|
39
|
+
* Represents a translation link that can be accessed, either as a published
|
|
40
|
+
* translation (`available`) or as a draft (`draft`).
|
|
41
|
+
*/
|
|
42
|
+
type AccessibleTranslationLink<L extends string> = AvailableLink<L> | DraftLink<L>;
|
|
43
|
+
/**
|
|
44
|
+
* Creates an available translation link.
|
|
45
|
+
*/
|
|
46
|
+
declare function availableLink<L extends string>(href: string, lang: L): AvailableLink<L>;
|
|
47
|
+
/**
|
|
48
|
+
* Creates a missing translation link.
|
|
49
|
+
*/
|
|
50
|
+
declare function missingLink<L extends string>(lang: L): MissingLink<L>;
|
|
51
|
+
/**
|
|
52
|
+
* Creates a draft translation link.
|
|
53
|
+
*/
|
|
54
|
+
declare function draftLink<L extends string>(href: string, lang: L): DraftLink<L>;
|
|
55
|
+
/** Returns whether the link is accessible. */
|
|
56
|
+
declare function isAccessible<L extends string>(link: TranslationLink<L>): link is AccessibleTranslationLink<L>;
|
|
57
|
+
/** Returns whether the link is available. */
|
|
58
|
+
declare function isAvailable<L extends string>(link: TranslationLink<L>): link is AvailableLink<L>;
|
|
59
|
+
/** Returns whether the link is a draft. */
|
|
60
|
+
declare function isDraft<L extends string>(link: TranslationLink<L>): link is DraftLink<L>;
|
|
61
|
+
/** Returns whether the link is missing. */
|
|
62
|
+
declare function isMissing<L extends string>(link: TranslationLink<L>): link is MissingLink<L>;
|
|
63
|
+
/**
|
|
64
|
+
* Resolves the default accessible translation link from a collection of links.
|
|
65
|
+
*
|
|
66
|
+
* Selection priority:
|
|
67
|
+
* 1. An `available` link matching `defaultLang`.
|
|
68
|
+
* 2. The first `available` link.
|
|
69
|
+
* 3. A `draft` link matching `defaultLang`.
|
|
70
|
+
* 4. The first `draft` link.
|
|
71
|
+
*
|
|
72
|
+
* @template L Type representing the supported locales.
|
|
73
|
+
* @param links Translation links to evaluate.
|
|
74
|
+
* @param defaultLang Preferred locale to prioritize during selection.
|
|
75
|
+
* @returns The selected accessible translation link.
|
|
76
|
+
*
|
|
77
|
+
* @throws {Error} If `links` is empty or if no accessible link exists.
|
|
78
|
+
*/
|
|
79
|
+
declare function resolveDefaultAccessibleLink<L extends string>(links: TranslationLink<L>[], defaultLang: L): AccessibleTranslationLink<L>;
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/core/standalone.d.ts
|
|
82
|
+
/**
|
|
83
|
+
* Represents a localized standalone page.
|
|
84
|
+
*/
|
|
85
|
+
interface StandalonePageEntry {
|
|
86
|
+
/**
|
|
87
|
+
* Route to the page.
|
|
88
|
+
*/
|
|
89
|
+
route: string;
|
|
90
|
+
/**
|
|
91
|
+
* Indicates whether the page exists only as a draft.
|
|
92
|
+
*/
|
|
93
|
+
draft?: boolean;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Maps a page identifier to its localized standalone page entries.
|
|
97
|
+
*
|
|
98
|
+
* The first key represents the page identifier and the nested keys represent
|
|
99
|
+
* locales.
|
|
100
|
+
*/
|
|
101
|
+
type StandalonePageIndex<K extends string, L extends string> = Record<K, Partial<Record<L, StandalonePageEntry>>>;
|
|
102
|
+
/**
|
|
103
|
+
* Creates translation links for a standalone page.
|
|
104
|
+
*
|
|
105
|
+
* Validates that:
|
|
106
|
+
* - the translation key is indexed
|
|
107
|
+
* - the current route belongs to the indexed translation group
|
|
108
|
+
*/
|
|
109
|
+
declare function createStandalonePageLinks<L extends string>(path: string, translationKey: string, index: StandalonePageIndex<string, L>, locales: Locales<L>): TranslationLink<L>[];
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/core/extract-id.d.ts
|
|
112
|
+
/**
|
|
113
|
+
* Extracts the locale and cleanId from an entryId of the form "es/my-post".
|
|
114
|
+
* Locale validation is delegated to the Locales value object.
|
|
115
|
+
*
|
|
116
|
+
* @param entryId Raw entry identifier (e.g., "es/my-post")
|
|
117
|
+
* @param locales Locales value object created via createLocales()
|
|
118
|
+
* @returns An object containing the validated locale and cleanId
|
|
119
|
+
* @throws Error if the entryId is malformed or the locale is invalid
|
|
120
|
+
*/
|
|
121
|
+
declare function extractCleanId<L extends string>(entryId: string, locales: Locales<L>): {
|
|
122
|
+
locale: L;
|
|
123
|
+
id: string;
|
|
124
|
+
};
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/section/routes.d.ts
|
|
127
|
+
type SectionDictionary<Section extends string, Language extends string, TValue> = Record<Section, Record<Language, TValue>>;
|
|
128
|
+
/**
|
|
129
|
+
* Value object that encapsulates localized slugs per section and exposes
|
|
130
|
+
* a stable API for building localized URLs.
|
|
131
|
+
*
|
|
132
|
+
* Invariants:
|
|
133
|
+
* - Each (locale, slug) pair must be unique across all sections.
|
|
134
|
+
* - The object is constructed exclusively through `createSectionRoutes`.
|
|
135
|
+
*
|
|
136
|
+
* @template Section - Section keys (e.g., 'blog', 'talk').
|
|
137
|
+
* @template Language - Locale keys (e.g., 'es', 'en').
|
|
138
|
+
*/
|
|
139
|
+
interface SectionRoutes<Section extends string, Language extends string> {
|
|
140
|
+
/**
|
|
141
|
+
* Raw dictionary of localized slugs per section.
|
|
142
|
+
* This structure is immutable once the value object is created.
|
|
143
|
+
*/
|
|
144
|
+
readonly routes: Record<Section, Record<Language, string>>;
|
|
145
|
+
/**
|
|
146
|
+
* Returns the configured section identifiers.
|
|
147
|
+
*/
|
|
148
|
+
getSections(): Section[];
|
|
149
|
+
/**
|
|
150
|
+
* Returns the localized slug for a section.
|
|
151
|
+
*
|
|
152
|
+
* @param section Section identifier.
|
|
153
|
+
* @param locale Locale identifier.
|
|
154
|
+
* @returns Localized slug for the section.
|
|
155
|
+
*/
|
|
156
|
+
getSectionRoute(section: Section, locale: Language): string;
|
|
157
|
+
/**
|
|
158
|
+
* Returns the localized URL for a section, including locale prefix.
|
|
159
|
+
*
|
|
160
|
+
* @param section Section identifier.
|
|
161
|
+
* @param locale Locale identifier.
|
|
162
|
+
* @returns URL string for the section in the given locale.
|
|
163
|
+
*/
|
|
164
|
+
getSectionURL(section: Section, locale: Language): string;
|
|
165
|
+
/**
|
|
166
|
+
* Returns the localized URL for a content entry inside a section.
|
|
167
|
+
*
|
|
168
|
+
* @param section Section identifier.
|
|
169
|
+
* @param locale Locale identifier.
|
|
170
|
+
* @param slug Entry slug.
|
|
171
|
+
* @returns Full URL for the entry.
|
|
172
|
+
*/
|
|
173
|
+
getEntryURL(section: Section, locale: Language, slug: string): string;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Constructs a nominal SectionRoutes value from a raw SectionDictionary.
|
|
177
|
+
*
|
|
178
|
+
* This function enforces the domain invariants for localized section routes:
|
|
179
|
+
* - each (locale, slug) pair must be unique across all sections
|
|
180
|
+
* - the resulting value is branded as 'SectionRoutes'
|
|
181
|
+
*
|
|
182
|
+
* If any invariant is violated, an error is thrown and the SectionRoutes value
|
|
183
|
+
* is not constructed.
|
|
184
|
+
*
|
|
185
|
+
* @template Section - The section keys (e.g., 'blog', 'docs').
|
|
186
|
+
* @template Language - The language codes (e.g., 'es', 'en').
|
|
187
|
+
* @param routes Raw dictionary of localized slugs per section.
|
|
188
|
+
* @returns A branded SectionRoutes value.
|
|
189
|
+
*/
|
|
190
|
+
declare function createSectionRoutes<Section extends string, Language extends string>(routes: SectionDictionary<Section, Language, string>): SectionRoutes<Section, Language>;
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/section/translation-index.d.ts
|
|
193
|
+
/**
|
|
194
|
+
* Represents a localized entry in the translation system.
|
|
195
|
+
* @template E - The type of the content (e.g., { title: string, body: string }).
|
|
196
|
+
* @template C - The section of the application (e.g., 'blog', 'docs').
|
|
197
|
+
* @template L - The language code (e.g., 'es', 'en').
|
|
198
|
+
*/
|
|
199
|
+
interface LocalizedEntry<TEntry, L extends string> {
|
|
200
|
+
cleanId: string;
|
|
201
|
+
translationKey: string;
|
|
202
|
+
locale: L;
|
|
203
|
+
draft: boolean;
|
|
204
|
+
original: TEntry;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Represents a translation index that groups localized entries by their translation key and locale.
|
|
208
|
+
* @template L - The type of the language code (e.g., 'es', 'en').
|
|
209
|
+
* @template E - The type of the content (e.g., { title: string, body: string }).
|
|
210
|
+
* @template C - The section of the application (e.g., 'blog', 'docs').
|
|
211
|
+
*/
|
|
212
|
+
type TranslationIndex<L extends string, TEntry> = Record<string, Partial<Record<L, LocalizedEntry<TEntry, L>>>>;
|
|
213
|
+
/**
|
|
214
|
+
* Builds a translation index from an array of localized entries.
|
|
215
|
+
* The index groups entries by their translation key and locale.
|
|
216
|
+
* If duplicate entries for the same translation key and locale are found, an error is thrown.
|
|
217
|
+
* @template E - The type of the content (e.g., { title: string, body: string }).
|
|
218
|
+
* @template C - The section of the application (e.g., 'blog', 'docs').
|
|
219
|
+
* @template L - The language code (e.g., 'es', 'en').
|
|
220
|
+
* @param entries Entries to index
|
|
221
|
+
* @returns The translation index, grouped by translation key and locale
|
|
222
|
+
* @throws Error if duplicate entries for the same translation key and locale are found
|
|
223
|
+
*/
|
|
224
|
+
declare function createTranslationIndex<L extends string, TEntry>(entries: readonly LocalizedEntry<TEntry, L>[]): TranslationIndex<L, TEntry>;
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/section/entry-adapter.d.ts
|
|
227
|
+
interface GenericCollectionEntry<C extends string, TData> {
|
|
228
|
+
id: string;
|
|
229
|
+
collection: C;
|
|
230
|
+
data: TData;
|
|
231
|
+
}
|
|
232
|
+
declare function resolveTranslationKey<T extends object>(data: T, cleanId: string): string;
|
|
233
|
+
/**
|
|
234
|
+
* Converts a collection entry into a localized entry.
|
|
235
|
+
*
|
|
236
|
+
* Extracts the locale and clean ID from the entry ID and resolves the
|
|
237
|
+
* translation key used to group translations of the same content.
|
|
238
|
+
*
|
|
239
|
+
* @param entry Collection entry to adapt.
|
|
240
|
+
* @param locales Supported locales used to parse the entry ID.
|
|
241
|
+
* @returns The corresponding localized entry.
|
|
242
|
+
*/
|
|
243
|
+
declare function adaptToLocalizedEntry<C extends string, T extends object, L extends string, TEntry extends GenericCollectionEntry<C, T>>(entry: TEntry, locales: Locales<L>): LocalizedEntry<TEntry, L>;
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/section/detail-path.d.ts
|
|
246
|
+
type DetailPath<C extends string, L extends string, TEntry extends GenericCollectionEntry<C, object>> = {
|
|
247
|
+
params: {
|
|
248
|
+
locale: L;
|
|
249
|
+
section: string;
|
|
250
|
+
id: string;
|
|
251
|
+
};
|
|
252
|
+
props: {
|
|
253
|
+
entry: LocalizedEntry<TEntry, L>;
|
|
254
|
+
section: C;
|
|
255
|
+
siblings: TranslationIndex<L, TEntry>[string];
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Generates the static path definitions required to build localized detail pages
|
|
260
|
+
* for all entries across all configured sections.
|
|
261
|
+
*
|
|
262
|
+
* For each section defined in `routes`, this function:
|
|
263
|
+
* 1. Fetches the collection entries.
|
|
264
|
+
* 2. Adapts them into localized entries.
|
|
265
|
+
* 3. Groups translations by translation key.
|
|
266
|
+
* 4. Produces one path per localized entry, including its translation siblings.
|
|
267
|
+
*
|
|
268
|
+
* The resulting paths can be consumed by static site generators to create
|
|
269
|
+
* localized detail pages with access to the current entry and all of its
|
|
270
|
+
* translations.
|
|
271
|
+
*
|
|
272
|
+
* @template TEntry Entry type returned by the collection loader.
|
|
273
|
+
* @template E Entry data type.
|
|
274
|
+
* @template C Section identifiers (for example: `'blog' | 'docs'`).
|
|
275
|
+
* @template L Locale identifiers (for example: `'en' | 'es'`).
|
|
276
|
+
*
|
|
277
|
+
* @param routes Localized section routes used to resolve URL segments.
|
|
278
|
+
* @param fetchCollection Function that retrieves all entries belonging
|
|
279
|
+
* to a given section.
|
|
280
|
+
* @param allowedLocales Supported locales
|
|
281
|
+
*/
|
|
282
|
+
declare function getStaticPathsEntries<C extends string, E extends object, L extends string, TEntry extends GenericCollectionEntry<C, E>>(routes: SectionRoutes<C, L>, fetchCollection: (collection: C) => Promise<TEntry[]>, allowedLocales: Locales<L>): Promise<DetailPath<C, L, TEntry>[]>;
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/section/details-links.d.ts
|
|
285
|
+
/**
|
|
286
|
+
* Creates translation links for all supported locales.
|
|
287
|
+
*
|
|
288
|
+
* Existing translations are returned as `available` links. Missing
|
|
289
|
+
* translations are represented as `missing` links. The output preserves
|
|
290
|
+
* the order defined by `locales.all`.
|
|
291
|
+
*
|
|
292
|
+
* @param siblings Available translations indexed by locale.
|
|
293
|
+
* @param sectionRoutes Routes used to build localized URLs.
|
|
294
|
+
* @param locales Supported locales.
|
|
295
|
+
* @returns One translation link per supported locale.
|
|
296
|
+
*/
|
|
297
|
+
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
|
+
//#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 };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
//#region src/core/locale.ts
|
|
2
|
+
function isLocale(locales, lang) {
|
|
3
|
+
return locales.includes(lang);
|
|
4
|
+
}
|
|
5
|
+
function createLocales(locales) {
|
|
6
|
+
return {
|
|
7
|
+
all: locales,
|
|
8
|
+
fromString(lang) {
|
|
9
|
+
if (!lang) throw new TypeError(`Invalid language: ${lang}`);
|
|
10
|
+
if (isLocale(locales, lang)) return lang;
|
|
11
|
+
throw new TypeError(`Invalid language: ${lang}`);
|
|
12
|
+
},
|
|
13
|
+
isValid(lang) {
|
|
14
|
+
return isLocale(locales, lang);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/core/translationLink.ts
|
|
20
|
+
/**
|
|
21
|
+
* Creates an available translation link.
|
|
22
|
+
*/
|
|
23
|
+
function availableLink(href, lang) {
|
|
24
|
+
return {
|
|
25
|
+
type: "available",
|
|
26
|
+
href,
|
|
27
|
+
locale: lang
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Creates a missing translation link.
|
|
32
|
+
*/
|
|
33
|
+
function missingLink(lang) {
|
|
34
|
+
return {
|
|
35
|
+
type: "missing",
|
|
36
|
+
href: null,
|
|
37
|
+
locale: lang
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Creates a draft translation link.
|
|
42
|
+
*/
|
|
43
|
+
function draftLink(href, lang) {
|
|
44
|
+
return {
|
|
45
|
+
type: "draft",
|
|
46
|
+
href,
|
|
47
|
+
locale: lang
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Returns whether the link is accessible. */
|
|
51
|
+
function isAccessible(link) {
|
|
52
|
+
return link.type === "available" || link.type === "draft";
|
|
53
|
+
}
|
|
54
|
+
/** Returns whether the link is available. */
|
|
55
|
+
function isAvailable(link) {
|
|
56
|
+
return link.type === "available";
|
|
57
|
+
}
|
|
58
|
+
/** Returns whether the link is a draft. */
|
|
59
|
+
function isDraft(link) {
|
|
60
|
+
return link.type === "draft";
|
|
61
|
+
}
|
|
62
|
+
/** Returns whether the link is missing. */
|
|
63
|
+
function isMissing(link) {
|
|
64
|
+
return link.type === "missing";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolves the default accessible translation link from a collection of links.
|
|
68
|
+
*
|
|
69
|
+
* Selection priority:
|
|
70
|
+
* 1. An `available` link matching `defaultLang`.
|
|
71
|
+
* 2. The first `available` link.
|
|
72
|
+
* 3. A `draft` link matching `defaultLang`.
|
|
73
|
+
* 4. The first `draft` link.
|
|
74
|
+
*
|
|
75
|
+
* @template L Type representing the supported locales.
|
|
76
|
+
* @param links Translation links to evaluate.
|
|
77
|
+
* @param defaultLang Preferred locale to prioritize during selection.
|
|
78
|
+
* @returns The selected accessible translation link.
|
|
79
|
+
*
|
|
80
|
+
* @throws {Error} If `links` is empty or if no accessible link exists.
|
|
81
|
+
*/
|
|
82
|
+
function resolveDefaultAccessibleLink(links, defaultLang) {
|
|
83
|
+
if (!links || links.length === 0) throw new Error("resolveDefaultAccessibleLink: unexpected empty links array");
|
|
84
|
+
const defaultAny = links.find((l) => l.locale === defaultLang);
|
|
85
|
+
if (defaultAny && isAvailable(defaultAny)) return defaultAny;
|
|
86
|
+
const firstAvailable = links.find(isAvailable);
|
|
87
|
+
if (firstAvailable) return firstAvailable;
|
|
88
|
+
if (defaultAny && isDraft(defaultAny)) return defaultAny;
|
|
89
|
+
const firstDraft = links.find(isDraft);
|
|
90
|
+
if (firstDraft) return firstDraft;
|
|
91
|
+
throw new Error("resolveDefaultAccessibleLink: expected at least one accessible link");
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/core/extract-id.ts
|
|
95
|
+
/**
|
|
96
|
+
* Extracts the locale and cleanId from an entryId of the form "es/my-post".
|
|
97
|
+
* Locale validation is delegated to the Locales value object.
|
|
98
|
+
*
|
|
99
|
+
* @param entryId Raw entry identifier (e.g., "es/my-post")
|
|
100
|
+
* @param locales Locales value object created via createLocales()
|
|
101
|
+
* @returns An object containing the validated locale and cleanId
|
|
102
|
+
* @throws Error if the entryId is malformed or the locale is invalid
|
|
103
|
+
*/
|
|
104
|
+
function extractCleanId(entryId, locales) {
|
|
105
|
+
if (!entryId) throw new Error("entryId cannot be empty");
|
|
106
|
+
const firstSlash = entryId.indexOf("/");
|
|
107
|
+
if (firstSlash <= 0) throw new Error(`Invalid entryId "${entryId}" — missing locale prefix`);
|
|
108
|
+
const rawLocale = entryId.slice(0, firstSlash);
|
|
109
|
+
if (!locales.isValid(rawLocale)) throw new Error(`Invalid entryId "${entryId}". Unknown locale prefix "${rawLocale}". Expected one of: ${locales.all.join(", ")}.`);
|
|
110
|
+
return {
|
|
111
|
+
locale: rawLocale,
|
|
112
|
+
id: entryId.slice(firstSlash + 1)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/core/standalone.ts
|
|
117
|
+
/**
|
|
118
|
+
* Creates translation links for a standalone page.
|
|
119
|
+
*
|
|
120
|
+
* Validates that:
|
|
121
|
+
* - the translation key is indexed
|
|
122
|
+
* - the current route belongs to the indexed translation group
|
|
123
|
+
*/
|
|
124
|
+
function createStandalonePageLinks(path, translationKey, index, locales) {
|
|
125
|
+
const { locale: currentLocale, id: currentRoute } = extractCleanId(path, locales);
|
|
126
|
+
const group = index[translationKey];
|
|
127
|
+
if (!group) throw new Error(`Standalone page '${translationKey}' is not indexed.`);
|
|
128
|
+
const currentEntry = group[currentLocale];
|
|
129
|
+
if (!currentEntry) throw new Error(`Standalone page '${translationKey}' has no entry for locale '${currentLocale}'.`);
|
|
130
|
+
if (currentEntry.route !== currentRoute) throw new Error(`Route '${path}' does not belong to standalone page '${translationKey}'.`);
|
|
131
|
+
return locales.all.map((locale) => {
|
|
132
|
+
const entry = group[locale];
|
|
133
|
+
if (!entry) return missingLink(locale);
|
|
134
|
+
const href = `/${locale}/${entry.route}`;
|
|
135
|
+
if (entry.draft) return draftLink(href, locale);
|
|
136
|
+
return availableLink(href, locale);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/section/routes.ts
|
|
141
|
+
/**
|
|
142
|
+
* Constructs a nominal SectionRoutes value from a raw SectionDictionary.
|
|
143
|
+
*
|
|
144
|
+
* This function enforces the domain invariants for localized section routes:
|
|
145
|
+
* - each (locale, slug) pair must be unique across all sections
|
|
146
|
+
* - the resulting value is branded as 'SectionRoutes'
|
|
147
|
+
*
|
|
148
|
+
* If any invariant is violated, an error is thrown and the SectionRoutes value
|
|
149
|
+
* is not constructed.
|
|
150
|
+
*
|
|
151
|
+
* @template Section - The section keys (e.g., 'blog', 'docs').
|
|
152
|
+
* @template Language - The language codes (e.g., 'es', 'en').
|
|
153
|
+
* @param routes Raw dictionary of localized slugs per section.
|
|
154
|
+
* @returns A branded SectionRoutes value.
|
|
155
|
+
*/
|
|
156
|
+
function createSectionRoutes(routes) {
|
|
157
|
+
const seen = /* @__PURE__ */ new Map();
|
|
158
|
+
for (const section of Object.keys(routes)) {
|
|
159
|
+
const localized = routes[section];
|
|
160
|
+
for (const locale of Object.keys(localized)) {
|
|
161
|
+
const slug = localized[locale];
|
|
162
|
+
const key = `${locale}:${slug}`;
|
|
163
|
+
if (seen.has(key)) {
|
|
164
|
+
const other = seen.get(key);
|
|
165
|
+
throw new Error(`Duplicated route for locale "${locale}" and slug "${slug}" between sections "${other}" and "${section}".`);
|
|
166
|
+
}
|
|
167
|
+
seen.set(key, section);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const getSections = () => Object.keys(routes);
|
|
171
|
+
const getSectionRoute = (section, locale) => routes[section][locale];
|
|
172
|
+
const getSectionURL = (section, locale) => `/${locale}/${getSectionRoute(section, locale)}`;
|
|
173
|
+
const getEntryURL = (section, locale, slug) => `${getSectionURL(section, locale)}/${slug}`;
|
|
174
|
+
return {
|
|
175
|
+
routes,
|
|
176
|
+
getSections,
|
|
177
|
+
getSectionRoute,
|
|
178
|
+
getSectionURL,
|
|
179
|
+
getEntryURL
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/section/translation-index.ts
|
|
184
|
+
/**
|
|
185
|
+
* Builds a translation index from an array of localized entries.
|
|
186
|
+
* The index groups entries by their translation key and locale.
|
|
187
|
+
* If duplicate entries for the same translation key and locale are found, an error is thrown.
|
|
188
|
+
* @template E - The type of the content (e.g., { title: string, body: string }).
|
|
189
|
+
* @template C - The section of the application (e.g., 'blog', 'docs').
|
|
190
|
+
* @template L - The language code (e.g., 'es', 'en').
|
|
191
|
+
* @param entries Entries to index
|
|
192
|
+
* @returns The translation index, grouped by translation key and locale
|
|
193
|
+
* @throws Error if duplicate entries for the same translation key and locale are found
|
|
194
|
+
*/
|
|
195
|
+
function createTranslationIndex(entries) {
|
|
196
|
+
const map = /* @__PURE__ */ new Map();
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const key = entry.translationKey;
|
|
199
|
+
const locale = entry.locale;
|
|
200
|
+
let group = map.get(key);
|
|
201
|
+
if (!group) {
|
|
202
|
+
group = {};
|
|
203
|
+
map.set(key, group);
|
|
204
|
+
} else if (locale in group) throw new Error(`Duplicate translation for key "${key}" and locale "${locale}"`);
|
|
205
|
+
group[locale] = entry;
|
|
206
|
+
}
|
|
207
|
+
return Object.fromEntries(map);
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/section/entry-adapter.ts
|
|
211
|
+
function resolveTranslationKey(data, cleanId) {
|
|
212
|
+
return "translationKey" in data && typeof data.translationKey === "string" ? data.translationKey : cleanId;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Converts a collection entry into a localized entry.
|
|
216
|
+
*
|
|
217
|
+
* Extracts the locale and clean ID from the entry ID and resolves the
|
|
218
|
+
* translation key used to group translations of the same content.
|
|
219
|
+
*
|
|
220
|
+
* @param entry Collection entry to adapt.
|
|
221
|
+
* @param locales Supported locales used to parse the entry ID.
|
|
222
|
+
* @returns The corresponding localized entry.
|
|
223
|
+
*/
|
|
224
|
+
function adaptToLocalizedEntry(entry, locales) {
|
|
225
|
+
const { locale, id: cleanId } = extractCleanId(entry.id, locales);
|
|
226
|
+
const draft = "draft" in entry.data && entry.data.draft === true;
|
|
227
|
+
return {
|
|
228
|
+
cleanId,
|
|
229
|
+
locale,
|
|
230
|
+
translationKey: resolveTranslationKey(entry.data, cleanId),
|
|
231
|
+
draft,
|
|
232
|
+
original: entry
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/section/detail-path.ts
|
|
237
|
+
/**
|
|
238
|
+
* Generates the static path definitions required to build localized detail pages
|
|
239
|
+
* for all entries across all configured sections.
|
|
240
|
+
*
|
|
241
|
+
* For each section defined in `routes`, this function:
|
|
242
|
+
* 1. Fetches the collection entries.
|
|
243
|
+
* 2. Adapts them into localized entries.
|
|
244
|
+
* 3. Groups translations by translation key.
|
|
245
|
+
* 4. Produces one path per localized entry, including its translation siblings.
|
|
246
|
+
*
|
|
247
|
+
* The resulting paths can be consumed by static site generators to create
|
|
248
|
+
* localized detail pages with access to the current entry and all of its
|
|
249
|
+
* translations.
|
|
250
|
+
*
|
|
251
|
+
* @template TEntry Entry type returned by the collection loader.
|
|
252
|
+
* @template E Entry data type.
|
|
253
|
+
* @template C Section identifiers (for example: `'blog' | 'docs'`).
|
|
254
|
+
* @template L Locale identifiers (for example: `'en' | 'es'`).
|
|
255
|
+
*
|
|
256
|
+
* @param routes Localized section routes used to resolve URL segments.
|
|
257
|
+
* @param fetchCollection Function that retrieves all entries belonging
|
|
258
|
+
* to a given section.
|
|
259
|
+
* @param allowedLocales Supported locales
|
|
260
|
+
*/
|
|
261
|
+
async function getStaticPathsEntries(routes, fetchCollection, allowedLocales) {
|
|
262
|
+
const allPaths = [];
|
|
263
|
+
for (const sectionKey of routes.getSections()) {
|
|
264
|
+
const localizedEntries = (await fetchCollection(sectionKey)).map((entry) => adaptToLocalizedEntry(entry, allowedLocales));
|
|
265
|
+
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
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return allPaths;
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/section/details-links.ts
|
|
283
|
+
/**
|
|
284
|
+
* Creates translation links for all supported locales.
|
|
285
|
+
*
|
|
286
|
+
* Existing translations are returned as `available` links. Missing
|
|
287
|
+
* translations are represented as `missing` links. The output preserves
|
|
288
|
+
* the order defined by `locales.all`.
|
|
289
|
+
*
|
|
290
|
+
* @param siblings Available translations indexed by locale.
|
|
291
|
+
* @param sectionRoutes Routes used to build localized URLs.
|
|
292
|
+
* @param locales Supported locales.
|
|
293
|
+
* @returns One translation link per supported locale.
|
|
294
|
+
*/
|
|
295
|
+
function createDetailTranslationLinks(siblings, sectionRoutes, locales) {
|
|
296
|
+
return locales.all.map((locale) => {
|
|
297
|
+
const sibling = siblings[locale];
|
|
298
|
+
if (!sibling) return missingLink(locale);
|
|
299
|
+
const href = sectionRoutes.getEntryURL(sibling.original.collection, locale, sibling.cleanId);
|
|
300
|
+
return sibling.draft ? draftLink(href, locale) : availableLink(href, locale);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
export { adaptToLocalizedEntry, availableLink, createDetailTranslationLinks, createLocales, createSectionRoutes, createStandalonePageLinks, createTranslationIndex, draftLink, extractCleanId, getStaticPathsEntries, isAccessible, isAvailable, isDraft, isMissing, missingLink, resolveDefaultAccessibleLink, resolveTranslationKey };
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@secorto/i18n",
|
|
3
|
+
"version": "0.0.4",
|
|
4
|
+
"private": false,
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"description": "Framework-agnostic i18n primitive",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"steps",
|
|
9
|
+
"playwright",
|
|
10
|
+
"testing"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/secorto/secorto_web/blob/master/packages/i18n#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/secorto/secorto_web/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/secorto/secorto_web.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Sergio Carlos Orozco Torres",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./src/index.ts",
|
|
26
|
+
"import": "./dist/index.mjs",
|
|
27
|
+
"default": "./dist/index.mjs"
|
|
28
|
+
},
|
|
29
|
+
"./core": {
|
|
30
|
+
"types": "./src/core/index.ts",
|
|
31
|
+
"import": "./dist/core/index.mjs",
|
|
32
|
+
"default": "./dist/core/index.mjs"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"main": "./dist/index.mjs",
|
|
36
|
+
"types": "./src/index.ts",
|
|
37
|
+
"directories": {
|
|
38
|
+
"test": "tests"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"src"
|
|
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
|
+
"devDependencies": {
|
|
52
|
+
"vitest": "^4.1.10"
|
|
53
|
+
},
|
|
54
|
+
"tsdown": {
|
|
55
|
+
"entry": [
|
|
56
|
+
"src/index.ts"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}
|