@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
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Locales } from './locale'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extracts the locale and cleanId from an entryId of the form "es/my-post".
|
|
5
|
+
* Locale validation is delegated to the Locales value object.
|
|
6
|
+
*
|
|
7
|
+
* @param entryId Raw entry identifier (e.g., "es/my-post")
|
|
8
|
+
* @param locales Locales value object created via createLocales()
|
|
9
|
+
* @returns An object containing the validated locale and cleanId
|
|
10
|
+
* @throws Error if the entryId is malformed or the locale is invalid
|
|
11
|
+
*/
|
|
12
|
+
export function extractCleanId<L extends string>(
|
|
13
|
+
entryId: string,
|
|
14
|
+
locales: Locales<L>
|
|
15
|
+
): { locale: L; id: string } {
|
|
16
|
+
if (!entryId) {
|
|
17
|
+
throw new Error('entryId cannot be empty')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const firstSlash = entryId.indexOf('/')
|
|
21
|
+
if (firstSlash <= 0) {
|
|
22
|
+
throw new Error(`Invalid entryId "${entryId}" — missing locale prefix`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rawLocale = entryId.slice(0, firstSlash)
|
|
26
|
+
|
|
27
|
+
if (!locales.isValid(rawLocale)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Invalid entryId "${entryId}". Unknown locale prefix "${rawLocale}". Expected one of: ${locales.all.join(', ')}.`
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cleanId = entryId.slice(firstSlash + 1)
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
locale: rawLocale,
|
|
37
|
+
id: cleanId
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface Locales<L extends string> {
|
|
2
|
+
readonly all: readonly L[]
|
|
3
|
+
fromString(lang: string | undefined): L
|
|
4
|
+
isValid(lang: string): lang is L
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isLocale<L extends string>(
|
|
8
|
+
locales: readonly L[],
|
|
9
|
+
lang: string
|
|
10
|
+
): lang is L {
|
|
11
|
+
return locales.includes(lang as L)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createLocales<L extends string>(
|
|
15
|
+
locales: readonly L[]
|
|
16
|
+
): Locales<L> {
|
|
17
|
+
return {
|
|
18
|
+
all: locales,
|
|
19
|
+
|
|
20
|
+
fromString(lang) {
|
|
21
|
+
if (!lang) throw new TypeError(`Invalid language: ${lang}`)
|
|
22
|
+
if (isLocale(locales, lang)) return lang
|
|
23
|
+
throw new TypeError(`Invalid language: ${lang}`)
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
isValid(lang): lang is L {
|
|
27
|
+
return isLocale(locales, lang)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { extractCleanId } from './extract-id'
|
|
2
|
+
import type { Locales } from './locale'
|
|
3
|
+
import {
|
|
4
|
+
availableLink,
|
|
5
|
+
draftLink,
|
|
6
|
+
missingLink,
|
|
7
|
+
type TranslationLink,
|
|
8
|
+
} from './translationLink'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Represents a localized standalone page.
|
|
12
|
+
*/
|
|
13
|
+
export interface StandalonePageEntry {
|
|
14
|
+
/**
|
|
15
|
+
* Route to the page.
|
|
16
|
+
*/
|
|
17
|
+
route: string
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Indicates whether the page exists only as a draft.
|
|
21
|
+
*/
|
|
22
|
+
draft?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maps a page identifier to its localized standalone page entries.
|
|
27
|
+
*
|
|
28
|
+
* The first key represents the page identifier and the nested keys represent
|
|
29
|
+
* locales.
|
|
30
|
+
*/
|
|
31
|
+
export type StandalonePageIndex<
|
|
32
|
+
K extends string,
|
|
33
|
+
L extends string,
|
|
34
|
+
> = Record<
|
|
35
|
+
K,
|
|
36
|
+
Partial<Record<L, StandalonePageEntry>>
|
|
37
|
+
>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates translation links for a standalone page.
|
|
41
|
+
*
|
|
42
|
+
* Validates that:
|
|
43
|
+
* - the translation key is indexed
|
|
44
|
+
* - the current route belongs to the indexed translation group
|
|
45
|
+
*/
|
|
46
|
+
export function createStandalonePageLinks<
|
|
47
|
+
L extends string,
|
|
48
|
+
>(
|
|
49
|
+
path: string,
|
|
50
|
+
translationKey: string,
|
|
51
|
+
index: StandalonePageIndex<string, L>,
|
|
52
|
+
locales: Locales<L>,
|
|
53
|
+
): TranslationLink<L>[] {
|
|
54
|
+
const { locale: currentLocale, id: currentRoute } = extractCleanId(
|
|
55
|
+
path,
|
|
56
|
+
locales,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const group = index[translationKey]
|
|
60
|
+
|
|
61
|
+
if (!group) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Standalone page '${translationKey}' is not indexed.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const currentEntry = group[currentLocale]
|
|
68
|
+
|
|
69
|
+
if (!currentEntry) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Standalone page '${translationKey}' has no entry for locale '${currentLocale}'.`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (currentEntry.route !== currentRoute) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Route '${path}' does not belong to standalone page '${translationKey}'.`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return locales.all.map(locale => {
|
|
82
|
+
const entry = group[locale]
|
|
83
|
+
|
|
84
|
+
if (!entry) {
|
|
85
|
+
return missingLink(locale)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const href = `/${locale}/${entry.route}`
|
|
89
|
+
|
|
90
|
+
if (entry.draft) {
|
|
91
|
+
return draftLink(href, locale)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return availableLink(href, locale)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a translation that is available and can be accessed by users.
|
|
3
|
+
*/
|
|
4
|
+
export type AvailableLink<L extends string> = {
|
|
5
|
+
type: 'available'
|
|
6
|
+
href: string
|
|
7
|
+
locale: L
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Represents a translation that does not exist for the given locale.
|
|
12
|
+
*/
|
|
13
|
+
export type MissingLink<L extends string> = {
|
|
14
|
+
type: 'missing'
|
|
15
|
+
href: null
|
|
16
|
+
locale: L
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Represents a translation that exists as a draft but is not yet publicly available.
|
|
21
|
+
*/
|
|
22
|
+
export type DraftLink<L extends string> = {
|
|
23
|
+
type: 'draft'
|
|
24
|
+
href: string
|
|
25
|
+
locale: L
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Represents the state of a translation for a locale.
|
|
30
|
+
*/
|
|
31
|
+
export type TranslationLink<L extends string> =
|
|
32
|
+
| AvailableLink<L>
|
|
33
|
+
| MissingLink<L>
|
|
34
|
+
| DraftLink<L>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Represents a translation link that can be accessed, either as a published
|
|
38
|
+
* translation (`available`) or as a draft (`draft`).
|
|
39
|
+
*/
|
|
40
|
+
export type AccessibleTranslationLink<L extends string> =
|
|
41
|
+
| AvailableLink<L>
|
|
42
|
+
| DraftLink<L>
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates an available translation link.
|
|
47
|
+
*/
|
|
48
|
+
export function availableLink<L extends string>(
|
|
49
|
+
href: string,
|
|
50
|
+
lang: L
|
|
51
|
+
): AvailableLink<L> {
|
|
52
|
+
return { type: 'available', href, locale: lang }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a missing translation link.
|
|
57
|
+
*/
|
|
58
|
+
export function missingLink<L extends string>(
|
|
59
|
+
lang: L
|
|
60
|
+
): MissingLink<L> {
|
|
61
|
+
return { type: 'missing', href: null, locale: lang }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates a draft translation link.
|
|
66
|
+
*/
|
|
67
|
+
export function draftLink<L extends string>(
|
|
68
|
+
href: string,
|
|
69
|
+
lang: L
|
|
70
|
+
): DraftLink<L> {
|
|
71
|
+
return { type: 'draft', href, locale: lang }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Returns whether the link is accessible. */
|
|
75
|
+
export function isAccessible<L extends string>(link: TranslationLink<L>): link is AccessibleTranslationLink<L> {
|
|
76
|
+
return link.type === 'available' || link.type === 'draft'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Returns whether the link is available. */
|
|
80
|
+
export function isAvailable<L extends string>(link: TranslationLink<L>): link is AvailableLink<L> {
|
|
81
|
+
return link.type === 'available'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Returns whether the link is a draft. */
|
|
85
|
+
export function isDraft<L extends string>(link: TranslationLink<L>): link is DraftLink<L> {
|
|
86
|
+
return link.type === 'draft'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Returns whether the link is missing. */
|
|
90
|
+
export function isMissing<L extends string>(link: TranslationLink<L>): link is MissingLink<L> {
|
|
91
|
+
return link.type === 'missing'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolves the default accessible translation link from a collection of links.
|
|
96
|
+
*
|
|
97
|
+
* Selection priority:
|
|
98
|
+
* 1. An `available` link matching `defaultLang`.
|
|
99
|
+
* 2. The first `available` link.
|
|
100
|
+
* 3. A `draft` link matching `defaultLang`.
|
|
101
|
+
* 4. The first `draft` link.
|
|
102
|
+
*
|
|
103
|
+
* @template L Type representing the supported locales.
|
|
104
|
+
* @param links Translation links to evaluate.
|
|
105
|
+
* @param defaultLang Preferred locale to prioritize during selection.
|
|
106
|
+
* @returns The selected accessible translation link.
|
|
107
|
+
*
|
|
108
|
+
* @throws {Error} If `links` is empty or if no accessible link exists.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveDefaultAccessibleLink<L extends string>(
|
|
111
|
+
links: TranslationLink<L>[],
|
|
112
|
+
defaultLang: L
|
|
113
|
+
): AccessibleTranslationLink<L> {
|
|
114
|
+
if (!links || links.length === 0) throw new Error('resolveDefaultAccessibleLink: unexpected empty links array')
|
|
115
|
+
|
|
116
|
+
const defaultAny = links.find(l => l.locale === defaultLang)
|
|
117
|
+
if (defaultAny && isAvailable(defaultAny)) return defaultAny
|
|
118
|
+
|
|
119
|
+
const firstAvailable = links.find(isAvailable)
|
|
120
|
+
if (firstAvailable) return firstAvailable
|
|
121
|
+
|
|
122
|
+
if (defaultAny && isDraft(defaultAny)) return defaultAny
|
|
123
|
+
|
|
124
|
+
const firstDraft = links.find(isDraft)
|
|
125
|
+
if (firstDraft) return firstDraft
|
|
126
|
+
|
|
127
|
+
throw new Error(
|
|
128
|
+
'resolveDefaultAccessibleLink: expected at least one accessible link'
|
|
129
|
+
)
|
|
130
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Locales } from '../core'
|
|
2
|
+
import {
|
|
3
|
+
createTranslationIndex,
|
|
4
|
+
LocalizedEntry,
|
|
5
|
+
TranslationIndex,
|
|
6
|
+
} from './translation-index'
|
|
7
|
+
import {
|
|
8
|
+
adaptToLocalizedEntry,
|
|
9
|
+
GenericCollectionEntry,
|
|
10
|
+
} from './entry-adapter'
|
|
11
|
+
import { SectionRoutes } from './routes'
|
|
12
|
+
|
|
13
|
+
export type DetailPath<
|
|
14
|
+
C extends string,
|
|
15
|
+
L extends string,
|
|
16
|
+
TEntry extends GenericCollectionEntry<C, object>,
|
|
17
|
+
> = {
|
|
18
|
+
params: {
|
|
19
|
+
locale: L
|
|
20
|
+
section: string
|
|
21
|
+
id: string
|
|
22
|
+
}
|
|
23
|
+
props: {
|
|
24
|
+
entry: LocalizedEntry<TEntry, L>
|
|
25
|
+
section: C
|
|
26
|
+
siblings: TranslationIndex<L, TEntry>[string]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generates the static path definitions required to build localized detail pages
|
|
32
|
+
* for all entries across all configured sections.
|
|
33
|
+
*
|
|
34
|
+
* For each section defined in `routes`, this function:
|
|
35
|
+
* 1. Fetches the collection entries.
|
|
36
|
+
* 2. Adapts them into localized entries.
|
|
37
|
+
* 3. Groups translations by translation key.
|
|
38
|
+
* 4. Produces one path per localized entry, including its translation siblings.
|
|
39
|
+
*
|
|
40
|
+
* The resulting paths can be consumed by static site generators to create
|
|
41
|
+
* localized detail pages with access to the current entry and all of its
|
|
42
|
+
* translations.
|
|
43
|
+
*
|
|
44
|
+
* @template TEntry Entry type returned by the collection loader.
|
|
45
|
+
* @template E Entry data type.
|
|
46
|
+
* @template C Section identifiers (for example: `'blog' | 'docs'`).
|
|
47
|
+
* @template L Locale identifiers (for example: `'en' | 'es'`).
|
|
48
|
+
*
|
|
49
|
+
* @param routes Localized section routes used to resolve URL segments.
|
|
50
|
+
* @param fetchCollection Function that retrieves all entries belonging
|
|
51
|
+
* to a given section.
|
|
52
|
+
* @param allowedLocales Supported locales
|
|
53
|
+
*/
|
|
54
|
+
export async function getStaticPathsEntries<
|
|
55
|
+
C extends string,
|
|
56
|
+
E extends object,
|
|
57
|
+
L extends string,
|
|
58
|
+
TEntry extends GenericCollectionEntry<C, E>,
|
|
59
|
+
>(
|
|
60
|
+
routes: SectionRoutes<C, L>,
|
|
61
|
+
fetchCollection: (
|
|
62
|
+
collection: C,
|
|
63
|
+
) => Promise<TEntry[]>,
|
|
64
|
+
allowedLocales: Locales<L>,
|
|
65
|
+
): Promise<DetailPath<C, L, TEntry>[]> {
|
|
66
|
+
|
|
67
|
+
const allPaths: DetailPath<C, L, TEntry>[] = []
|
|
68
|
+
|
|
69
|
+
for (const sectionKey of routes.getSections()) {
|
|
70
|
+
const rawEntries = await fetchCollection(sectionKey)
|
|
71
|
+
|
|
72
|
+
const localizedEntries = rawEntries.map(entry =>
|
|
73
|
+
adaptToLocalizedEntry(entry, allowedLocales),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const index = createTranslationIndex(localizedEntries)
|
|
77
|
+
|
|
78
|
+
for (const localized of localizedEntries) {
|
|
79
|
+
allPaths.push({
|
|
80
|
+
params: {
|
|
81
|
+
locale: localized.locale,
|
|
82
|
+
section: routes.getSectionRoute(sectionKey, localized.locale),
|
|
83
|
+
id: localized.cleanId,
|
|
84
|
+
},
|
|
85
|
+
props: {
|
|
86
|
+
entry: localized,
|
|
87
|
+
section: sectionKey,
|
|
88
|
+
siblings: index[localized.translationKey],
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return allPaths
|
|
95
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates translation links for all supported locales.
|
|
8
|
+
*
|
|
9
|
+
* Existing translations are returned as `available` links. Missing
|
|
10
|
+
* translations are represented as `missing` links. The output preserves
|
|
11
|
+
* the order defined by `locales.all`.
|
|
12
|
+
*
|
|
13
|
+
* @param siblings Available translations indexed by locale.
|
|
14
|
+
* @param sectionRoutes Routes used to build localized URLs.
|
|
15
|
+
* @param locales Supported locales.
|
|
16
|
+
* @returns One translation link per supported locale.
|
|
17
|
+
*/
|
|
18
|
+
export function createDetailTranslationLinks<
|
|
19
|
+
C extends string,
|
|
20
|
+
L extends string,
|
|
21
|
+
TEntry extends GenericCollectionEntry<C, object>,
|
|
22
|
+
>(
|
|
23
|
+
siblings: Partial<Record<L, LocalizedEntry<TEntry, L>>>,
|
|
24
|
+
sectionRoutes: SectionRoutes<C, L>,
|
|
25
|
+
locales: Locales<L>,
|
|
26
|
+
): TranslationLink<L>[] {
|
|
27
|
+
return locales.all.map(locale => {
|
|
28
|
+
const sibling = siblings[locale]
|
|
29
|
+
|
|
30
|
+
if (!sibling) {
|
|
31
|
+
return missingLink(locale)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const href = sectionRoutes.getEntryURL(
|
|
35
|
+
sibling.original.collection,
|
|
36
|
+
locale,
|
|
37
|
+
sibling.cleanId,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return sibling.draft ? draftLink(href, locale) : availableLink(href, locale)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { extractCleanId, Locales } from '../core'
|
|
2
|
+
import { LocalizedEntry } from './translation-index'
|
|
3
|
+
|
|
4
|
+
export interface GenericCollectionEntry<
|
|
5
|
+
C extends string,
|
|
6
|
+
TData
|
|
7
|
+
> {
|
|
8
|
+
id: string
|
|
9
|
+
collection: C
|
|
10
|
+
data: TData
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveTranslationKey<T extends object>(
|
|
14
|
+
data: T,
|
|
15
|
+
cleanId: string
|
|
16
|
+
): string {
|
|
17
|
+
return 'translationKey' in data &&
|
|
18
|
+
typeof (data as Record<string, unknown>).translationKey === 'string'
|
|
19
|
+
? (data as Record<string, unknown>).translationKey as string
|
|
20
|
+
: cleanId
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Converts a collection entry into a localized entry.
|
|
25
|
+
*
|
|
26
|
+
* Extracts the locale and clean ID from the entry ID and resolves the
|
|
27
|
+
* translation key used to group translations of the same content.
|
|
28
|
+
*
|
|
29
|
+
* @param entry Collection entry to adapt.
|
|
30
|
+
* @param locales Supported locales used to parse the entry ID.
|
|
31
|
+
* @returns The corresponding localized entry.
|
|
32
|
+
*/
|
|
33
|
+
export function adaptToLocalizedEntry<
|
|
34
|
+
C extends string,
|
|
35
|
+
T extends object,
|
|
36
|
+
L extends string,
|
|
37
|
+
TEntry extends GenericCollectionEntry<C, T>
|
|
38
|
+
>(
|
|
39
|
+
entry: TEntry,
|
|
40
|
+
locales: Locales<L>
|
|
41
|
+
): LocalizedEntry<TEntry, L> {
|
|
42
|
+
const { locale, id: cleanId } =
|
|
43
|
+
extractCleanId(entry.id, locales)
|
|
44
|
+
|
|
45
|
+
const draft = 'draft' in entry.data && entry.data.draft === true
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
cleanId,
|
|
49
|
+
locale,
|
|
50
|
+
translationKey: resolveTranslationKey(
|
|
51
|
+
entry.data,
|
|
52
|
+
cleanId
|
|
53
|
+
),
|
|
54
|
+
draft,
|
|
55
|
+
original: entry,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export type SectionDictionary<
|
|
2
|
+
Section extends string,
|
|
3
|
+
Language extends string,
|
|
4
|
+
TValue
|
|
5
|
+
> = Record<
|
|
6
|
+
Section,
|
|
7
|
+
Record<Language, TValue>
|
|
8
|
+
>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Value object that encapsulates localized slugs per section and exposes
|
|
12
|
+
* a stable API for building localized URLs.
|
|
13
|
+
*
|
|
14
|
+
* Invariants:
|
|
15
|
+
* - Each (locale, slug) pair must be unique across all sections.
|
|
16
|
+
* - The object is constructed exclusively through `createSectionRoutes`.
|
|
17
|
+
*
|
|
18
|
+
* @template Section - Section keys (e.g., 'blog', 'talk').
|
|
19
|
+
* @template Language - Locale keys (e.g., 'es', 'en').
|
|
20
|
+
*/
|
|
21
|
+
export interface SectionRoutes<
|
|
22
|
+
Section extends string,
|
|
23
|
+
Language extends string
|
|
24
|
+
> {
|
|
25
|
+
/**
|
|
26
|
+
* Raw dictionary of localized slugs per section.
|
|
27
|
+
* This structure is immutable once the value object is created.
|
|
28
|
+
*/
|
|
29
|
+
readonly routes: Record<Section, Record<Language, string>>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns the configured section identifiers.
|
|
33
|
+
*/
|
|
34
|
+
getSections(): Section[]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns the localized slug for a section.
|
|
38
|
+
*
|
|
39
|
+
* @param section Section identifier.
|
|
40
|
+
* @param locale Locale identifier.
|
|
41
|
+
* @returns Localized slug for the section.
|
|
42
|
+
*/
|
|
43
|
+
getSectionRoute(section: Section, locale: Language): string
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns the localized URL for a section, including locale prefix.
|
|
47
|
+
*
|
|
48
|
+
* @param section Section identifier.
|
|
49
|
+
* @param locale Locale identifier.
|
|
50
|
+
* @returns URL string for the section in the given locale.
|
|
51
|
+
*/
|
|
52
|
+
getSectionURL(section: Section, locale: Language): string
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns the localized URL for a content entry inside a section.
|
|
56
|
+
*
|
|
57
|
+
* @param section Section identifier.
|
|
58
|
+
* @param locale Locale identifier.
|
|
59
|
+
* @param slug Entry slug.
|
|
60
|
+
* @returns Full URL for the entry.
|
|
61
|
+
*/
|
|
62
|
+
getEntryURL(section: Section, locale: Language, slug: string): string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Constructs a nominal SectionRoutes value from a raw SectionDictionary.
|
|
67
|
+
*
|
|
68
|
+
* This function enforces the domain invariants for localized section routes:
|
|
69
|
+
* - each (locale, slug) pair must be unique across all sections
|
|
70
|
+
* - the resulting value is branded as 'SectionRoutes'
|
|
71
|
+
*
|
|
72
|
+
* If any invariant is violated, an error is thrown and the SectionRoutes value
|
|
73
|
+
* is not constructed.
|
|
74
|
+
*
|
|
75
|
+
* @template Section - The section keys (e.g., 'blog', 'docs').
|
|
76
|
+
* @template Language - The language codes (e.g., 'es', 'en').
|
|
77
|
+
* @param routes Raw dictionary of localized slugs per section.
|
|
78
|
+
* @returns A branded SectionRoutes value.
|
|
79
|
+
*/
|
|
80
|
+
export function createSectionRoutes<
|
|
81
|
+
Section extends string,
|
|
82
|
+
Language extends string
|
|
83
|
+
>(
|
|
84
|
+
routes: SectionDictionary<Section, Language, string>
|
|
85
|
+
): SectionRoutes<Section, Language> {
|
|
86
|
+
const seen = new Map<string, Section>()
|
|
87
|
+
|
|
88
|
+
for (const section of Object.keys(routes) as Section[]) {
|
|
89
|
+
const localized = routes[section]
|
|
90
|
+
|
|
91
|
+
for (const locale of Object.keys(localized) as Language[]) {
|
|
92
|
+
const slug = localized[locale]
|
|
93
|
+
const key = `${locale}:${slug}`
|
|
94
|
+
|
|
95
|
+
if (seen.has(key)) {
|
|
96
|
+
const other = seen.get(key)!
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Duplicated route for locale "${locale}" and slug "${slug}" between sections "${other}" and "${section}".`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
seen.set(key, section)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const getSections = (): Section[] => Object.keys(routes) as Section[]
|
|
107
|
+
|
|
108
|
+
const getSectionRoute = (section: Section, locale: Language): string =>
|
|
109
|
+
routes[section][locale]
|
|
110
|
+
|
|
111
|
+
const getSectionURL = (section: Section, locale: Language): string =>
|
|
112
|
+
`/${locale}/${getSectionRoute(section, locale)}`
|
|
113
|
+
|
|
114
|
+
const getEntryURL = (
|
|
115
|
+
section: Section,
|
|
116
|
+
locale: Language,
|
|
117
|
+
slug: string
|
|
118
|
+
): string =>
|
|
119
|
+
`${getSectionURL(section, locale)}/${slug}`
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
routes,
|
|
123
|
+
getSections,
|
|
124
|
+
getSectionRoute,
|
|
125
|
+
getSectionURL,
|
|
126
|
+
getEntryURL
|
|
127
|
+
}
|
|
128
|
+
}
|