@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.
@@ -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,4 @@
1
+ export * from './locale'
2
+ export * from './translationLink'
3
+ export * from './standalone'
4
+ export * from './extract-id'
@@ -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,2 @@
1
+ export * from './core'
2
+ export * from './section'
@@ -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,5 @@
1
+ export * from './routes'
2
+ export * from './translation-index'
3
+ export * from './entry-adapter'
4
+ export * from './detail-path'
5
+ export * from './details-links'
@@ -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
+ }